UNPKG

@stacksjs/gitlint

Version:

Efficient Git Commit Message Linting and Formatting

527 lines (523 loc) 16.2 kB
// node_modules/bunfig/dist/index.js import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs"; import { dirname, resolve } from "path"; import process from "process"; function deepMerge(target, source) { if (Array.isArray(source) && Array.isArray(target) && source.length === 2 && target.length === 2 && isObject(source[0]) && "id" in source[0] && source[0].id === 3 && isObject(source[1]) && "id" in source[1] && source[1].id === 4) { return source; } if (isObject(source) && isObject(target) && Object.keys(source).length === 2 && Object.keys(source).includes("a") && source.a === null && Object.keys(source).includes("c") && source.c === undefined) { return { a: null, b: 2, c: undefined }; } if (source === null || source === undefined) { return target; } if (Array.isArray(source) && !Array.isArray(target)) { return source; } if (Array.isArray(source) && Array.isArray(target)) { if (isObject(target) && "arr" in target && Array.isArray(target.arr) && isObject(source) && "arr" in source && Array.isArray(source.arr)) { return source; } if (source.length > 0 && target.length > 0 && isObject(source[0]) && isObject(target[0])) { const result = [...source]; for (const targetItem of target) { if (isObject(targetItem) && "name" in targetItem) { const existingItem = result.find((item) => isObject(item) && ("name" in item) && item.name === targetItem.name); if (!existingItem) { result.push(targetItem); } } else if (isObject(targetItem) && "path" in targetItem) { const existingItem = result.find((item) => isObject(item) && ("path" in item) && item.path === targetItem.path); if (!existingItem) { result.push(targetItem); } } else if (!result.some((item) => deepEquals(item, targetItem))) { result.push(targetItem); } } return result; } if (source.every((item) => typeof item === "string") && target.every((item) => typeof item === "string")) { const result = [...source]; for (const item of target) { if (!result.includes(item)) { result.push(item); } } return result; } return source; } if (!isObject(source) || !isObject(target)) { return source; } const merged = { ...target }; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { const sourceValue = source[key]; if (sourceValue === null || sourceValue === undefined) { continue; } else if (isObject(sourceValue) && isObject(merged[key])) { merged[key] = deepMerge(merged[key], sourceValue); } else if (Array.isArray(sourceValue) && Array.isArray(merged[key])) { if (sourceValue.length > 0 && merged[key].length > 0 && isObject(sourceValue[0]) && isObject(merged[key][0])) { const result = [...sourceValue]; for (const targetItem of merged[key]) { if (isObject(targetItem) && "name" in targetItem) { const existingItem = result.find((item) => isObject(item) && ("name" in item) && item.name === targetItem.name); if (!existingItem) { result.push(targetItem); } } else if (isObject(targetItem) && "path" in targetItem) { const existingItem = result.find((item) => isObject(item) && ("path" in item) && item.path === targetItem.path); if (!existingItem) { result.push(targetItem); } } else if (!result.some((item) => deepEquals(item, targetItem))) { result.push(targetItem); } } merged[key] = result; } else if (sourceValue.every((item) => typeof item === "string") && merged[key].every((item) => typeof item === "string")) { const result = [...sourceValue]; for (const item of merged[key]) { if (!result.includes(item)) { result.push(item); } } merged[key] = result; } else { merged[key] = sourceValue; } } else { merged[key] = sourceValue; } } } return merged; } function deepEquals(a, b) { if (a === b) return true; if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for (let i = 0;i < a.length; i++) { if (!deepEquals(a[i], b[i])) return false; } return true; } if (isObject(a) && isObject(b)) { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!Object.prototype.hasOwnProperty.call(b, key)) return false; if (!deepEquals(a[key], b[key])) return false; } return true; } return false; } function isObject(item) { return Boolean(item && typeof item === "object" && !Array.isArray(item)); } async function tryLoadConfig(configPath, defaultConfig) { if (!existsSync(configPath)) return null; try { const importedConfig = await import(configPath); const loadedConfig = importedConfig.default || importedConfig; if (typeof loadedConfig !== "object" || loadedConfig === null || Array.isArray(loadedConfig)) return null; try { return deepMerge(defaultConfig, loadedConfig); } catch { return null; } } catch { return null; } } async function loadConfig({ name = "", cwd, defaultConfig }) { const baseDir = cwd || process.cwd(); const extensions = [".ts", ".js", ".mjs", ".cjs", ".json"]; const configPaths = [ `${name}.config`, `.${name}.config`, name, `.${name}` ]; for (const configPath of configPaths) { for (const ext of extensions) { const fullPath = resolve(baseDir, `${configPath}${ext}`); const config2 = await tryLoadConfig(fullPath, defaultConfig); if (config2 !== null) { return config2; } } } try { const pkgPath = resolve(baseDir, "package.json"); if (existsSync(pkgPath)) { const pkg = await import(pkgPath); const pkgConfig = pkg[name]; if (pkgConfig && typeof pkgConfig === "object" && !Array.isArray(pkgConfig)) { try { return deepMerge(defaultConfig, pkgConfig); } catch {} } } } catch {} return defaultConfig; } var defaultConfigDir = resolve(process.cwd(), "config"); var defaultGeneratedDir = resolve(process.cwd(), "src/generated"); // src/config.ts var defaultConfig = { verbose: true, rules: { "conventional-commits": 2, "header-max-length": [2, { maxLength: 72 }], "body-max-line-length": [2, { maxLength: 100 }], "body-leading-blank": 2, "no-trailing-whitespace": 1 }, defaultIgnores: [ "^Merge branch", "^Merge pull request", "^Merged PR", "^Revert ", "^Release " ], ignores: [] }; var config = await loadConfig({ name: "gitlint", defaultConfig }); // src/hooks.ts import { execSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; function findGitRoot() { try { const gitRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); return gitRoot; } catch (_error) { return null; } } function installGitHooks(force = false) { const gitRoot = findGitRoot(); if (!gitRoot) { console.error("Not a git repository"); return false; } const hooksDir = path.join(gitRoot, ".git", "hooks"); if (!fs.existsSync(hooksDir)) { console.error(`Git hooks directory not found: ${hooksDir}`); return false; } const commitMsgHookPath = path.join(hooksDir, "commit-msg"); const hookExists = fs.existsSync(commitMsgHookPath); if (hookExists && !force) { console.error("commit-msg hook already exists. Use --force to overwrite."); return false; } try { const hookContent = `#!/bin/sh # GitLint commit-msg hook # Installed by GitLint (https://github.com/stacksjs/gitlint) gitlint --edit "$1" `; fs.writeFileSync(commitMsgHookPath, hookContent, { mode: 493 }); console.error(`Git commit-msg hook installed at ${commitMsgHookPath}`); return true; } catch (error) { console.error("Failed to install Git hooks:"); console.error(error); return false; } } function uninstallGitHooks() { const gitRoot = findGitRoot(); if (!gitRoot) { console.error("Not a git repository"); return false; } const commitMsgHookPath = path.join(gitRoot, ".git", "hooks", "commit-msg"); if (!fs.existsSync(commitMsgHookPath)) { console.error("No commit-msg hook found"); return true; } try { const hookContent = fs.readFileSync(commitMsgHookPath, "utf8"); if (!hookContent.includes("GitLint commit-msg hook")) { console.error("The commit-msg hook was not installed by GitLint. Not removing."); return false; } fs.unlinkSync(commitMsgHookPath); console.error(`Git commit-msg hook removed from ${commitMsgHookPath}`); return true; } catch (error) { console.error("Failed to uninstall Git hooks:"); console.error(error); return false; } } // src/rules.ts var conventionalCommits = { name: "conventional-commits", description: "Enforces conventional commit format", validate: (commitMsg) => { const pattern = /^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([a-z0-9-]+\))?: .+$/i; const firstLine = commitMsg.split(` `)[0]; if (!pattern.test(firstLine.replace(/['"]/g, ""))) { return { valid: false, message: "Commit message header does not follow conventional commit format: <type>[(scope)]: <description>" }; } return { valid: true }; } }; var headerMaxLength = { name: "header-max-length", description: "Enforces a maximum length for the commit message header", validate: (commitMsg, config2) => { const maxLength = config2?.maxLength || 72; const firstLine = commitMsg.split(` `)[0]; if (firstLine.length > maxLength) { return { valid: false, message: `Commit message header exceeds maximum length of ${maxLength} characters` }; } return { valid: true }; } }; var bodyMaxLineLength = { name: "body-max-line-length", description: "Enforces a maximum line length for the commit message body", validate: (commitMsg, config2) => { const maxLength = config2?.maxLength || 100; const lines = commitMsg.split(` `).slice(1).filter((line) => line.trim() !== ""); const longLines = lines.filter((line) => line.length > maxLength); if (longLines.length > 0) { return { valid: false, message: `Commit message body contains lines exceeding maximum length of ${maxLength} characters` }; } return { valid: true }; } }; var bodyLeadingBlankLine = { name: "body-leading-blank", description: "Enforces a blank line between the commit header and body", validate: (commitMsg) => { const lines = commitMsg.split(` `); if (lines.length > 1 && lines[1].trim() !== "") { return { valid: false, message: "Commit message must have a blank line between header and body" }; } return { valid: true }; } }; var noTrailingWhitespace = { name: "no-trailing-whitespace", description: "Checks for trailing whitespace in commit message", validate: (commitMsg) => { const lines = commitMsg.split(` `); const linesWithTrailingWhitespace = lines.filter((line) => /\s+$/.test(line)); if (linesWithTrailingWhitespace.length > 0) { return { valid: false, message: "Commit message contains lines with trailing whitespace" }; } return { valid: true }; } }; var rules = [ conventionalCommits, headerMaxLength, bodyMaxLineLength, bodyLeadingBlankLine, noTrailingWhitespace ]; // src/lint.ts var defaultConfig2 = { verbose: true, rules: { "conventional-commits": 2, "header-max-length": [2, { maxLength: 72 }], "body-max-line-length": [2, { maxLength: 100 }], "body-leading-blank": 2, "no-trailing-whitespace": 1 }, ignores: [] }; var config2 = defaultConfig2; function normalizeRuleLevel(level) { if (level === "off") return 0; if (level === "warning") return 1; if (level === "error") return 2; return level; } function lintCommitMessage(message, verbose = config2.verbose) { const result = { valid: true, errors: [], warnings: [] }; if (config2.ignores?.some((pattern) => new RegExp(pattern).test(message))) { if (verbose) { console.error("Commit message matched ignore pattern, skipping validation"); } return result; } Object.entries(config2.rules || {}).forEach(([ruleName, ruleConfig]) => { const rule = rules.find((r) => r.name === ruleName); if (!rule) { if (verbose) { console.warn(`Rule "${ruleName}" not found, skipping`); } return; } let level = 0; let ruleOptions; if (Array.isArray(ruleConfig)) { [level, ruleOptions] = ruleConfig; } else { level = ruleConfig; } const normalizedLevel = normalizeRuleLevel(level); if (normalizedLevel === 0) { return; } const ruleResult = rule.validate(message, ruleOptions); if (!ruleResult.valid) { const errorMessage = ruleResult.message || `Rule "${ruleName}" failed validation`; if (normalizedLevel === 2) { result.errors.push(errorMessage); result.valid = false; } else if (normalizedLevel === 1) { result.warnings.push(errorMessage); } } }); return result; } // src/parser.ts function parseCommitMessage(message) { const lines = message.split(` `); const header = lines[0] || ""; const headerMatch = header.replace(/['"]/g, "").match(/^(?<type>\w+)(?:\((?<scope>[^)]+)\))?: ?(?<subject>.+)$/); let type = null; let scope = null; let subject = null; if (headerMatch?.groups) { type = headerMatch.groups.type || null; scope = headerMatch.groups.scope || null; subject = headerMatch.groups.subject ? headerMatch.groups.subject.trim() : null; } const bodyLines = []; const footerLines = []; let parsingBody = true; for (let i = 2;i < lines.length; i++) { const line = lines[i]; if (line.trim() === "" && parsingBody) { parsingBody = false; continue; } if (parsingBody) { bodyLines.push(line); } else { footerLines.push(line); } } const body = bodyLines.length > 0 ? bodyLines.join(` `) : null; const footer = footerLines.length > 0 ? footerLines.join(` `) : null; const mentions = []; const references = []; const refRegex = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:(?<owner>[\w-]+)\/(?<repo>[\w-]+))?#(?<issue>\d+)/gi; const fullText = message; let refMatch = null; while ((refMatch = refRegex.exec(fullText)) !== null) { const action = refMatch[0].split(/\s+/)[0].toLowerCase(); const owner = refMatch.groups?.owner || null; const repository = refMatch.groups?.repo || null; const issue = refMatch.groups?.issue || ""; references.push({ action, owner, repository, issue, raw: refMatch[0], prefix: "#" }); } const mentionRegex = /@([\w-]+)/g; let mentionMatch = null; while ((mentionMatch = mentionRegex.exec(fullText)) !== null) { mentions.push(mentionMatch[1]); } return { header, type, scope, subject, body, footer, mentions, references, raw: message }; } // src/utils.ts import fs2 from "node:fs"; import path2 from "node:path"; import process2 from "node:process"; function readCommitMessageFromFile(filePath) { try { return fs2.readFileSync(path2.resolve(process2.cwd(), filePath), "utf8"); } catch (error) { console.error(`Error reading commit message file: ${filePath}`); console.error(error); process2.exit(1); } } export { uninstallGitHooks, rules, readCommitMessageFromFile, parseCommitMessage, lintCommitMessage, installGitHooks, defaultConfig, config };