diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1403d842..74ae3a6f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,31 @@ concurrency: cancel-in-progress: true jobs: + format: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: yarn + cache-dependency-path: yarn.lock + + - name: Install dependencies + run: yarn --frozen-lockfile + + - name: Check changed-file formatting + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA" + lint: runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/docs/code-quality.md b/docs/code-quality.md index 89451e17..872dc3c5 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -105,7 +105,7 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件, 3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`。 4. 文件集合为空时正常通过。 -本地直接运行 `yarn format` 或 `yarn format:check` 时,脚本以可用的 `upstream/v2`、`origin/v2` 或本地 `v2` 为基线,并合并当前工作区与未跟踪文件。CI 使用显式引用保证输入可复现: +本地直接运行 `yarn format` 或 `yarn format:check` 时,脚本按 `refs/remotes/upstream/v2`、`refs/remotes/origin/v2`、`refs/heads/v2` 的顺序选择与当前提交具有共同祖先的基线,并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义;CI 使用显式提交 SHA 保证输入可复现: ```sh yarn format:check --base --head @@ -120,7 +120,7 @@ yarn format:check --base --head 具体交付拆分为: 1. Prettier 基础设施 PR:安装项目 CLI,定义配置、命令、变更文件选择脚本和回归测试,不修改 workflow。 -2. 变更文件门禁 PR:复用已合并脚本接入 Pull Request workflow;验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名后,将该稳定 check 配置为 required。 +2. 变更文件门禁 PR:复用已合并脚本接入 Pull Request workflow;验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名。初期作为普通 check 观察,稳定后再由维护者通过仓库规则独立配置为 required。 3. 全仓门禁 PR:仅在存量收敛条件满足后择机实施,不与变更文件门禁绑定。 ## 全仓格式门禁的启用条件 @@ -129,6 +129,8 @@ yarn format:check --base --head Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 203 个存量文件需要格式化。该数字是收敛起点而不是忽略白名单;变更文件仍必须完整通过检查,存量数量随日常修改逐步下降。 +变更文件 CI 接入前在 `v2` 再次测量为 206 个文件,较基础设施合并后的 201 个文件出现回升。这说明仅提供本地命令不足以阻止新改动扩大存量;变更文件 check 用于守住新增 diff,不因此要求在本阶段批量格式化已有文件。 + 变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check: - 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。 diff --git a/package.json b/package.json index 7f1270e3..821d8b48 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "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", + "test:format-config": "vitest run tests/config/format-changed.spec.ts tests/config/frontend-workflow.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" diff --git a/scripts/format-changed.mjs b/scripts/format-changed.mjs index faefb788..9f94d9c0 100644 --- a/scripts/format-changed.mjs +++ b/scripts/format-changed.mjs @@ -5,7 +5,8 @@ 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 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)) @@ -16,39 +17,44 @@ function parseNullDelimited(output) { /** 运行只返回仓库相对路径的 Git 查询。 */ function gitPaths(cwd, args) { - return parseNullDelimited(execFileSync('git', args, { cwd, encoding: 'buffer' })) + return parseNullDelimited( + execFileSync('git', args, { + cwd, + encoding: 'buffer', + maxBuffer: GIT_OUTPUT_MAX_BUFFER, + }), + ) } -/** 判断本地仓库是否存在可用于三点 diff 的提交引用。 */ -function hasCommitRef(cwd, ref) { - return ( - spawnSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { - cwd, - stdio: 'ignore', - }).status === 0 - ) +/** 尝试把引用解析为提交 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) { - try { - return execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], { - cwd, - encoding: 'utf8', - }).trim() - } catch { - throw new Error(`无法解析 Git 提交引用:${ref}`) - } + const commit = tryResolveCommit(cwd, ref) + if (commit) return commit + 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 显式指定比较起点。') +/** 判断两个提交能否进行三点比较。 */ +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 } - return base + throw new Error('无法定位可比较的 v2 基线,请通过 --base 显式指定比较起点。') } /** @@ -61,16 +67,17 @@ function collectChangedPaths({ cwd, 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 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=ACMR', '-z', `${baseCommit}...${headCommit}`]), + gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', `${baseCommit}...${headCommit}`]), ) if (!explicitRange) { - for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMR', '-z', 'HEAD'])) { + 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'])) { @@ -178,7 +185,7 @@ function parseArguments(args) { return options } -/** 执行变更文件选择与格式化;显式引用模式为后续 PR workflow 提供确定输入。 */ +/** 执行变更文件选择与格式化;显式引用模式为 CI 提供确定输入。 */ async function main() { const cwd = process.cwd() const { action, base, head } = parseArguments(process.argv.slice(2)) diff --git a/tests/config/format-changed.spec.ts b/tests/config/format-changed.spec.ts index d3ea0185..f7a2dee2 100644 --- a/tests/config/format-changed.spec.ts +++ b/tests/config/format-changed.spec.ts @@ -1,7 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process' -import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, resolve } from 'node:path' +import { delimiter, dirname, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' const selectorScript = resolve(process.cwd(), 'scripts/format-changed.mjs') @@ -36,15 +36,21 @@ function createRepository() { return repository } -function listSelected(repository: string, ...args: string[]) { +function runSelector(repository: string, args: string[], env = process.env) { const output = execFileSync(process.execPath, [selectorScript, '--list', ...args], { cwd: repository, encoding: 'utf8', + env, + maxBuffer: 128 * 1024 * 1024, }) return JSON.parse(output) as string[] } +function listSelected(repository: string, ...args: string[]) { + return runSelector(repository, args) +} + afterEach(() => { for (const repository of temporaryRepositories.splice(0)) { rmSync(repository, { recursive: true, force: true }) @@ -124,4 +130,85 @@ describe('Prettier 变更文件选择器', () => { expect(readFileSync(resolve(repository, '--special [file].ts'), 'utf8')).toBe('export const special = true;\n') }) + + it('本地基线使用完整分支引用,不受同名标签影响', () => { + const repository = createRepository() + git(repository, 'switch', '-c', 'feature') + write(repository, 'src/modified.ts', "export const value='feature'\n") + git(repository, 'add', 'src/modified.ts') + git(repository, 'commit', '-m', 'feature') + git(repository, 'tag', 'v2', 'HEAD') + + expect(listSelected(repository)).toEqual(['src/modified.ts']) + }) + + it('首个候选没有 merge base 时回退到后续可比较分支', () => { + const repository = createRepository() + const baseCommit = git(repository, 'rev-parse', 'HEAD') + git(repository, 'switch', '-c', 'feature') + write(repository, 'src/modified.ts', "export const value='feature'\n") + git(repository, 'add', 'src/modified.ts') + git(repository, 'commit', '-m', 'feature') + const tree = git(repository, 'rev-parse', `${baseCommit}^{tree}`) + const unrelatedCommit = execFileSync('git', ['commit-tree', tree, '-m', 'unrelated'], { + cwd: repository, + encoding: 'utf8', + }).trim() + git(repository, 'update-ref', 'refs/remotes/upstream/v2', unrelatedCommit) + git(repository, 'update-ref', 'refs/remotes/origin/v2', baseCommit) + + expect(listSelected(repository)).toEqual(['src/modified.ts']) + }) + + it('纳入转为普通文件的类型变更,并继续排除最终为符号链接的路径', () => { + const repository = createRepository() + symlinkSync('modified.ts', resolve(repository, 'src/to-regular.ts')) + write(repository, 'src/to-link.ts', 'export const link = false\n') + git(repository, 'add', 'src/to-regular.ts', 'src/to-link.ts') + git(repository, 'commit', '-m', 'type-change base') + git(repository, 'switch', '-c', 'feature') + rmSync(resolve(repository, 'src/to-regular.ts')) + write(repository, 'src/to-regular.ts', 'export const regular=true\n') + rmSync(resolve(repository, 'src/to-link.ts')) + symlinkSync('modified.ts', resolve(repository, 'src/to-link.ts')) + git(repository, 'add', '-A') + git(repository, 'commit', '-m', 'type changes') + + expect(listSelected(repository, '--base', 'v2', '--head', 'HEAD')).toEqual(['src/to-regular.ts']) + }) + + it('Git 路径输出超过默认缓冲区时仍能完成选择', () => { + const repository = createRepository() + const wrapperDirectory = resolve(repository, '.test-bin') + const wrapperPath = resolve(wrapperDirectory, 'git') + write( + repository, + '.test-bin/git', + `#!/usr/bin/env node +const { spawnSync } = require('node:child_process') +const { writeSync } = require('node:fs') +const args = process.argv.slice(2) +if (args[0] === 'ls-files') { + const output = Buffer.from('src/modified.ts\\0'.repeat(300_000)) + let offset = 0 + while (offset < output.length) offset += writeSync(1, output, offset) + process.exit(0) +} +const result = spawnSync('git', args, { + env: { ...process.env, PATH: process.env.REAL_GIT_PATH }, + stdio: 'inherit', +}) +process.exit(result.status ?? 1) +`, + ) + chmodSync(wrapperPath, 0o755) + + expect( + runSelector(repository, [], { + ...process.env, + PATH: `${wrapperDirectory}${delimiter}${process.env.PATH ?? ''}`, + REAL_GIT_PATH: process.env.PATH ?? '', + }), + ).toEqual(['src/modified.ts']) + }) }) diff --git a/tests/config/frontend-workflow.spec.ts b/tests/config/frontend-workflow.spec.ts new file mode 100644 index 00000000..4c2da69a --- /dev/null +++ b/tests/config/frontend-workflow.spec.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const workflowPath = resolve(process.cwd(), '.github/workflows/test.yml') + +describe('前端 Pull Request workflow', () => { + it('使用只读的变更文件格式检查,并显式比较事件 base/head SHA', () => { + const workflow = readFileSync(workflowPath, 'utf8') + const formatJob = workflow.match(/\n {2}format:\n(?[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job + + expect(workflow).toContain('permissions:\n contents: read') + expect(formatJob).toBeDefined() + expect(formatJob).toContain('fetch-depth: 0') + expect(formatJob).toContain("node-version: '24'") + expect(formatJob).toContain('run: yarn --frozen-lockfile') + expect(formatJob).toContain('BASE_SHA: ${{ github.event.pull_request.base.sha }}') + expect(formatJob).toContain('HEAD_SHA: ${{ github.event.pull_request.head.sha }}') + expect(formatJob).toContain('run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"') + expect(formatJob).not.toContain('--write') + }) +})