@stacksjs/gitlint
Version:
Efficient Git Commit Message Linting and Formatting
527 lines (523 loc) • 16.2 kB
JavaScript
// 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
};