UNPKG

@ruan-cat/commitlint-config

Version:
241 lines (209 loc) 8.04 kB
import { join } from "node:path"; import * as fs from "node:fs"; import { globSync } from "tinyglobby"; import { minimatch } from "minimatch"; import { load } from "js-yaml"; import { isUndefined } from "lodash-es"; import { type PackageJson } from "pkg-types"; import { type PnpmWorkspace } from "@ruan-cat/utils"; // 注意 整个 commitlint-config 包都是使用 cjs 的语法,所以需要使用 node-cjs 的语法 import { printList, isMonorepoProject } from "@ruan-cat/utils/node-cjs"; import { createPackagescopes } from "./utils.ts"; import { commonScopes } from "./common-scopes.ts"; import { execSync } from "node:child_process"; import consola from "consola"; /** * 解析 git status --porcelain 输出,提取暂存区文件路径 * @description * 1. 按行分割输出 * 2. 过滤空行 * 3. 只保留暂存区文件(第一个字符不是空格且不是?) * 4. 提取文件路径(从第3个字符开始) * @param gitStatusOutput - git status --porcelain 命令的输出 * @returns 暂存区文件路径数组 */ export function parseGitStatusOutput(gitStatusOutput: string): string[] { return gitStatusOutput .split("\n") .filter((line) => line.length > 0) .filter((line) => { // git status --porcelain 格式:XY filename // X: 索引状态(暂存区),Y: 工作目录状态 // 只处理暂存区的文件(第一个字符不是空格且不是?) const indexStatus = line[0]; return indexStatus !== " " && indexStatus !== "?"; }) .map((line) => { // git status --porcelain 格式:XY filename // 从第3个字符开始是文件名,但需要去掉可能的前导空格 let filePath = line.substring(2).trim(); return filePath; }) .filter((filePath) => filePath.length > 0); } /** * 获取包路径到范围值的映射关系 */ function getPackagePathToScopeMapping(): Map<string, string> { const mapping = new Map<string, string>(); // 判断是否是 monorepo 项目 if (!isMonorepoProject()) { // 如果不是 monorepo,不添加默认映射,依赖 glob 匹配来确定范围 return mapping; } // 读取 pnpm-workspace.yaml 文件 const workspaceConfigPath = join(process.cwd(), "pnpm-workspace.yaml"); const workspaceFile = fs.readFileSync(workspaceConfigPath, "utf8"); const workspaceConfig = <PnpmWorkspace>load(workspaceFile); /** * packages配置 包的匹配语法 * @description * 此时已经通过 isMonorepoProject() 验证,packages 一定存在且有效 */ const pkgPatterns = workspaceConfig.packages!; /** * 过滤后的包匹配模式 * @description * 过滤掉 negation patterns(以 ! 开头)和空字符串 * negation patterns 由 pnpm 自身处理,不应传递给 glob 工具 */ const filteredPkgPatterns = pkgPatterns.filter((pattern) => { if (pattern.startsWith("!")) return false; // 排除 negation patterns if (pattern.trim() === "") return false; // 排除空字符串 return true; }); // 根据每个模式匹配相应的目录 filteredPkgPatterns.forEach((pkgPattern) => { const globPattern = `${pkgPattern}/package.json`; const matchedPaths = globSync(globPattern, { cwd: process.cwd(), ignore: ["**/node_modules/**"], }); matchedPaths.forEach((relativePkgPath) => { const fullPkgJsonPath = join(process.cwd(), relativePkgPath); if (fs.existsSync(fullPkgJsonPath)) { // 防御性检查1: 验证文件路径确实以 package.json 结尾 if (!relativePkgPath.endsWith("package.json")) { consola.warn(`跳过非 package.json 文件: ${relativePkgPath}`); return; } try { // 读取文件内容 const fileContent = fs.readFileSync(fullPkgJsonPath, "utf-8"); // 防御性检查2: 验证文件内容以 { 开头,确认为有效 JSON const trimmedContent = fileContent.trim(); if (!trimmedContent.startsWith("{")) { consola.warn(`跳过无效的 JSON 文件(内容不以 { 开头): ${relativePkgPath}`); return; } const pkgJson = <PackageJson>JSON.parse(fileContent); const scope = createPackagescopes(pkgJson); // 获取包的目录路径(移除 package.json) const packageRelativePath = relativePkgPath.replace(/[/\\]package\.json$/, "").replace(/\\/g, "/"); mapping.set(packageRelativePath, scope); } catch (error) { // 防御性检查3: 捕获 JSON.parse 错误 consola.error(`解析 package.json 失败: ${relativePkgPath}`, error); } } }); }); // 不再默认添加根目录映射,root 范围应该通过 glob 匹配来确定 return mapping; } /** * 根据 git 状态,获取默认的提交范围 * @description * 1. 从 getPackagesNameAndDescription 获取所有包信息 * 2. 从 git status --porcelain 获取修改的文件路径 * 3. 匹配被修改的包范围,返回这些范围 * @see https://cz-git.qbb.sh/zh/recipes/default-scope * @returns 返回被修改的包范围数组,如果只有一个则返回字符串 */ export function getDefaultScope(): string | string[] | undefined { try { // 1. 获取包路径到范围的映射 const pathToScopeMapping = getPackagePathToScopeMapping(); // consola.warn("pathToScopeMapping", pathToScopeMapping); // 2. 获取 git 修改的文件列表 const gitStatusOutput = execSync("git status --porcelain || true").toString(); // printList({ // title: (files) => `输出 git status --porcelain || true 命令的输出:`, // stringList: gitStatusOutput.split("\n"), // }); if (!gitStatusOutput) { consola.info("没有检测到文件修改"); return undefined; } // 3. 解析修改的文件路径 const modifiedFiles = parseGitStatusOutput(gitStatusOutput); // 输出修改的文件列表 printList({ title: (files) => `输出 ${files.length} 个暂存区文件路径:`, stringList: modifiedFiles, }); // 4. 匹配文件路径到包范围 const affectedScopes = new Set<string>(); modifiedFiles.forEach((filePath) => { let matchedScope: string | undefined = undefined; // 不设置默认值,只有真正匹配时才设置 let maxMatchLength = 0; // 找到最长匹配的包路径 for (const [packagePath, scope] of pathToScopeMapping.entries()) { if (packagePath === "") { // 空路径代表根目录,优先级最低 continue; } // 检查文件是否在这个包目录下 const normalizedPackagePath = packagePath.replace(/\\/g, "/"); const normalizedFilePath = filePath.replace(/\\/g, "/"); if ( normalizedFilePath.startsWith(normalizedPackagePath + "/") || normalizedFilePath === normalizedPackagePath ) { // 选择最长匹配的路径(最具体的包) if (packagePath.length > maxMatchLength) { maxMatchLength = packagePath.length; matchedScope = scope; } } } // 只有当真正匹配到包路径时才添加范围 if (matchedScope !== undefined) { affectedScopes.add(matchedScope); } // 新增:基于 commonScopes 的 glob 匹配 const normalizedFilePath = filePath.replace(/\\/g, "/"); commonScopes.forEach((scopeItem) => { // 检查是否存在 glob 字段 if (scopeItem.glob && scopeItem.glob.length > 0) { // 遍历每个 glob 模式 scopeItem.glob.forEach((globPattern) => { // 使用 minimatch 进行 glob 匹配 if (minimatch(normalizedFilePath, globPattern)) { // 匹配成功,添加该范围的 value 到集合中 affectedScopes.add(scopeItem.value); } }); } }); }); const scopesArray = Array.from(affectedScopes); // 5. 返回结果 if (scopesArray.length === 0) { consola.info("本次修改没有影响任何包范围"); return undefined; } else if (scopesArray.length === 1) { return scopesArray[0]; } else { // 输出影响的包范围 printList({ title: "影响的包范围:", stringList: scopesArray, }); return scopesArray; } } catch (error) { consola.error("获取默认范围时出错:", error); return undefined; } }