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
JavaScript
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 };