UNPKG

commit-parser

Version:

A tiny parser for conventional commits that extracts metadata like type, scope, breaking changes and references

252 lines (247 loc) 7.86 kB
import { exec, execSync } from "node:child_process"; import { quansync } from "quansync/macro"; //#region src/git.ts /** @internal */ const execCommand = quansync({ sync: (cmd, cwd) => { return execSync(cmd, { encoding: "utf8", cwd, stdio: "pipe" }).toString(); }, async: (cmd, cwd) => { return new Promise((resolve, reject) => { exec(cmd, { encoding: "utf8", cwd }, (error, stdout) => { if (error) reject(error); else resolve(stdout); }); }); } }); /** * The format of git log. * * commit_short_hash | commit_hash | subject | author_name | author_email | author_date | body * * @see {@link https://git-scm.com/docs/pretty-formats | documentation} for details. */ const GIT_LOG_FORMAT = "----%n%h|%H|%s|%an|%ae|%ad|%b"; /** * Retrieves raw git commit strings from a git repository. * * @param {GetRawGitCommitStringsOptions} options - Configuration options for fetching git commits * @returns {QuansyncFn<string[], [options: GetRawGitCommitStringsOptions]>} An array of raw git commit strings, or an empty array if no commits are found or an error occurs * * @example * ```typescript * // Get all commits up to HEAD * const commits = await getRawGitCommitStrings({ to: "HEAD" }); * * // Get commits between two references * const commits = await getRawGitCommitStrings({ from: "v1.0.0", to: "HEAD" }); * * // Get commits from a specific folder * const commits = await getRawGitCommitStrings({ to: "HEAD", folder: "src" }); * ``` * * @example * ```typescript * // Synchronous usage * const commits = getRawGitCommitStrings.sync({ from: "v1.0.0" }); * ``` */ const getRawGitCommitStrings = quansync(function* (options) { const { from, to = "HEAD", cwd, folder } = options; const folderPath = folder ? ` -- ${folder}` : ""; const cmd = `git --no-pager log "${from ? `${from}...${to}` : to}" --pretty="${GIT_LOG_FORMAT}"${folderPath}`; try { return (yield* execCommand(cmd, cwd)).trim().split("----\n").filter(Boolean); } catch { return []; } }); //#endregion //#region src/parse.ts const ConventionalCommitRegex = /(?<emoji>:.+:|(\uD83C[\uDF00-\uDFFF])|(\uD83D[\uDC00-\uDE4F\uDE80-\uDEFF])|[\u2600-\u2B55])?( *)(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i; const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gi; const PullRequestRE = /\([ a-z]*(#\d+)\s*\)/g; const IssueRE = /(#\d+)/g; /** * Parses a raw git commit string into a structured format. * * @param {string} commit - A raw git commit string delimited by '|' character * @returns {RawGitCommit} A structured representation of the git commit */ function parseRawCommit(commit) { const [shortHash, hash, message, authorName, authorEmail, date, ..._body] = commit.split("|"); const body = _body.filter(Boolean).join("\n"); return { author: { name: authorName, email: authorEmail }, body, date, message, shortHash, hash }; } /** * Extracts references (pull requests and issues) from a commit description * * @param {string} description - The commit description to extract references from * @returns {object} Object containing references array and cleaned description */ function extractReferences(description, body) { const refs = /* @__PURE__ */ new Map(); function append(text) { for (const match of text.matchAll(PullRequestRE)) { const value = match[1]; refs.set(value, "pull-request"); } for (const match of text.matchAll(IssueRE)) { const value = match[1]; if (!refs.has(value)) refs.set(value, "issue"); } } append(description); if (body) append(body); return { references: Array.from(refs, ([value, type]) => ({ type, value })), cleanedDescription: description.replace(PullRequestRE, "").trim() }; } /** * Extracts co-authors from commit body * * @param {string} body - The commit body to extract co-authors from * @param {GitCommitAuthor} primaryAuthor - The primary commit author * @returns {GitCommitAuthor[]} Array of all authors (primary + co-authors) */ function extractAuthors(body, primaryAuthor) { const authors = [primaryAuthor]; for (const match of body.matchAll(CoAuthoredByRegex)) authors.push({ name: (match.groups?.name || "").trim(), email: (match.groups?.email || "").trim() }); return authors; } /** * Parses a raw git commit into a structured format with additional metadata * * @param {RawGitCommit} rawCommit - The raw git commit to parse * @returns {GitCommit} A structured representation of the git commit with additional metadata */ function parseCommit(rawCommit) { const { shortHash, hash, message, body, date: data } = rawCommit; const match = message.match(ConventionalCommitRegex); const isConventional = match !== null; const type = match?.groups?.type || ""; const scope = match?.groups?.scope; const rawDescription = match?.groups?.description || message; const isBreaking = Boolean(match?.groups?.breaking || /breaking[ -]changes?:/i.test(body)); const { references, cleanedDescription } = extractReferences(rawDescription, body); return { authors: extractAuthors(body, rawCommit.author), body, date: data, description: cleanedDescription, isBreaking, isConventional, message, references, scope, shortHash, hash, type }; } //#endregion //#region src/commits.ts /** * Retrieves and parses git commits from a repository. * * @param {GetCommitsOptions} options - Options for fetching and parsing git commits. * @returns {QuansyncFn<GitCommit[], [options: GetCommitsOptions]>} An array of structured git commits. * * @example * ```typescript * // Get all commits up to HEAD * const commits = await getCommits({ to: "HEAD" }); * * // Get commits between two references * const commits = await getCommits({ from: "v1.0.0", to: "HEAD" }); * ``` * * @example * ```typescript * // Synchronous usage * const commits = getCommits.sync({ from: "v1.0.0" }); * ``` */ const getCommits = quansync(function* (options) { return (yield* getRawGitCommitStrings({ from: options.from, to: options.to, cwd: options.cwd, folder: options.folder })).map(parseRawCommit).map(parseCommit); }); //#endregion //#region src/grouping.ts /** * Groups commits by their conventional commit type. Non-conventional commits * are optionally grouped under a configurable key. * * Rules: * - Conventional commits use their lowercased `type` value as key. * - Non-conventional commits are grouped under `nonConventionalKey` if * `includeNonConventional` is true; otherwise they are skipped. * - Commits without a type (when expected) are skipped. * * @param {GitCommit[]} commits List of commits to group. * @param {GroupByTypeOptions} opts Options controlling grouping behavior. * @returns {Map<string, GitCommit[]>} Map keyed by commit type (or `nonConventionalKey`) with arrays of commits. * * @example * ```ts * const result = groupByType(commits, { nonConventionalKey: 'other' }); * const featCommits = result.get('feat'); * const miscCommits = result.get('other'); * ``` */ function groupByType(commits, opts = {}) { const { includeNonConventional = true, nonConventionalKey = "misc", excludeKeys = [], mergeKeys } = opts; const groupedCommits = /* @__PURE__ */ new Map(); for (const commit of commits) { if (!commit.isConventional && !includeNonConventional) continue; let key; if (!commit.isConventional) key = nonConventionalKey; else { const commitType = (commit.type || "").toLowerCase(); if (!commitType) continue; key = commitType; } if (excludeKeys.includes(key)) continue; if (mergeKeys) { for (const [targetKey, keysToMerge] of Object.entries(mergeKeys)) if (keysToMerge.includes(key)) { key = targetKey; break; } } const group = groupedCommits.get(key) ?? []; group.push(commit); groupedCommits.set(key, group); } return groupedCommits; } //#endregion export { getCommits, groupByType, parseCommit, parseRawCommit };