--- trunk/git/genchangelog 2024/08/03 15:50:38 11 +++ trunk/git/genchangelog 2024/08/30 11:55:09 56 @@ -22,11 +22,14 @@ const path = require("path"); const { existsSync } = require("fs"); const { exit } = require("process"); -const { execSync } = require("child_process"); +const { execSync, exec, spawn } = require("child_process"); const { writeFile } = require("fs/promises"); const { parseArgs } = require("util"); +const crypto = require("crypto"); const ME = path.basename(process.argv[1]); +const GIT_COMMIT_BOUNDARY = crypto.randomBytes(64).toString("hex"); +const GIT_SPACE_BOUNDARY = crypto.randomBytes(64).toString("hex"); function print(...args) { console.log(`${ME}: ${args.join(" ")}`); @@ -37,9 +40,14 @@ } function findInPath(executable) { - for (const segment of process.env.PATH?.split(process.platform === "win32" ? ";" : ":") ?? []) { - const executablePath = path.join(segment, executable + (process.platform === "win32" ? ".exe" : "")); - + for (const segment of process.env.PATH?.split( + process.platform === "win32" ? ";" : ":", + ) ?? []) { + const executablePath = path.join( + segment, + executable + (process.platform === "win32" ? ".exe" : ""), + ); + if (existsSync(executablePath)) { return executablePath; } @@ -60,102 +68,268 @@ return gitPath; } -function getGitLog(gitPath) { +async function getGitLog(gitPath) { try { - return execSync(gitPath + " --no-pager log", { encoding: "utf8" }); - } - catch { - perror("command `git --no-pager log' failed"); + let output = ""; + + const child = spawn( + gitPath, + [ + "--no-pager", + "log", + `--pretty=format:%h %H %an${GIT_SPACE_BOUNDARY} %ae %ad${GIT_SPACE_BOUNDARY} %B${GIT_COMMIT_BOUNDARY}`, + ], + { encoding: "utf8", stdio: "pipe" }, + ); + + child.stdout.on("data", (data) => { + output += data; + }); + + child.stderr.on("data", (data) => { + console.error(data); + }); + + await new Promise((resolve) => { + child.on("close", (code) => { + if (code !== 0) { + perror("command `git log' failed with exit code " + code); + exit(1); + } else { + resolve(); + } + }); + }); + + return output; + } catch (error) { + console.error(error); + perror("command `git log' failed"); exit(1); } } +function strUntil(str, boundary) { + let output = ""; + let i = 0; + + for (i = 0; i < str.length; i++) { + if (str[i] === boundary[0]) { + let boundaryIndex = 0; + let previousI = i; + + while ( + boundaryIndex < boundary.length && + i < str.length && + str[i] === boundary[boundaryIndex] + ) { + i++; + boundaryIndex++; + } + + if (boundaryIndex === boundary.length) { + return { output, size: i }; + } + + i = previousI; + } + + output += str[i]; + } + + return { output, size: i }; +} + function parseGitLog(gitLog) { - const lines = gitLog.split("\n"); const commits = []; - for (let i = 0; i < lines.length; ) { - if (!lines[i].startsWith("commit")) { - i++; - continue; - } + let i = 0; - const [, hash] = lines[i++].split(' '); - const headerProps = {}; + while (i < gitLog.length) { + const { output, size } = strUntil(gitLog.slice(i), GIT_COMMIT_BOUNDARY); + i += size; - while (i < lines.length && lines[i].trim() !== "" && !/^\s/.test(lines[i])) { - const colonIndex = lines[i].indexOf(":"); - const name = lines[i].slice(0, colonIndex).toLowerCase(); - const value = lines[i].slice(colonIndex + 1).trim(); - headerProps[name] = value; - i++; - } + let outputIndex = 0; - const messageLines = []; + const shortIdSpaceIndex = output.indexOf(" "); + const idSpaceIndex = output.indexOf(" ", shortIdSpaceIndex + 1); + const shortId = output.slice(outputIndex, shortIdSpaceIndex); + const id = output.slice(shortIdSpaceIndex + 1, idSpaceIndex); - while (i < lines.length && !lines[i].startsWith("commit")) { - const lineToPush = lines[i++].replace(/^ /, ""); + outputIndex += shortId.length + id.length + 2; - if (!lineToPush) { - continue; - } - - messageLines.push(lineToPush); - } + const { output: authorName, size: authorNameSize } = strUntil( + output.slice(outputIndex), + GIT_SPACE_BOUNDARY, + ); + + outputIndex += authorNameSize + 1; + + const authorEmailSpaceIndex = output.indexOf(" ", outputIndex + 1); + const authorEmail = output.slice(outputIndex, authorEmailSpaceIndex); + + outputIndex += authorEmail.length + 1; + + const { output: date, size: dateSize } = strUntil( + output.slice(outputIndex), + GIT_SPACE_BOUNDARY, + ); + + outputIndex += dateSize + 1; + + const message = output.slice(outputIndex); + const newlineIndex = message.indexOf("\n"); + const subject = message.slice( + 0, + newlineIndex === -1 ? undefined : newlineIndex, + ); + const body = newlineIndex === -1 ? "" : message.slice(newlineIndex + 1); - let mindex = messageLines.length - 1; + const validFooterProps = [ + "signed-off-by", + "co-authored-by", + "on-behalf-of", + ]; const footerProps = {}; - const validFooterProps = ["signed-off-by", "co-authored-by", "on-behalf-of"]; - - while (mindex >= 1 && /^[A-Za-z0-9-]+: /.test(messageLines.at(mindex))) { - const messageLine = messageLines[mindex--]; - const colonIndex = messageLine.indexOf(":"); - const name = messageLine.slice(0, colonIndex).toLowerCase(); + const coAuthors = []; - if (!validFooterProps.includes(name)) { - continue; - } - - const value = messageLine.slice(colonIndex + 1).trim(); + for (const line of body.split("\n")) { + const colonIndex = line.indexOf(":"); + const key = line.slice(0, colonIndex).trim().toLowerCase(); + const value = line.slice(colonIndex + 1).trim(); - if (name in footerProps && !Array.isArray(footerProps[name])) { - footerProps[name] = [footerProps[name]]; + if (validFooterProps.includes(key)) { + footerProps[key] = value; } - if (Array.isArray(footerProps[name])) { - footerProps[name].push(value); - } - else { - footerProps[name] = value; - } + if (key === "co-authored-by") { + const name = value.slice(0, value.lastIndexOf(" ")).trim(); - messageLines.splice(mindex - 1, 1); + coAuthors.push({ + name, + email: value + .slice(name.length) + .trim() + .replace(/^<|>$/g, ""), + }); + } } - const message = messageLines.join("\n"); - commits.push({ - hash, - message, - headerProps, + shortId, + id, + author: { + name: authorName, + email: authorEmail, + }, + date: new Date(date), + subject, + body, footerProps, - signedOffBy: footerProps["signed-off-by"], - onBehalfOf: footerProps["on-behalf-of"], - author: headerProps["author"], - createdAt: new Date(headerProps["date"]) + message, + coAuthors, + authors: [ + { + name: authorName, + email: authorEmail, + }, + ...coAuthors, + ], }); } return commits; } +function escapeMarkdown(str) { + return str.replace(/([_*~`])/g, "\\$1"); +} + +function generateMarkdownChangelog(commits) { + let output = "# Changelog\n\n"; + + const grouppedCommitsByDate = {}; + + for (const commit of commits) { + const key = `${commit.date.getUTCDate().toString().padStart(2, 0)}-${( + commit.date.getUTCMonth() + 1 + ) + .toString() + .padStart(2, "0")}-${commit.date.getUTCFullYear()}::${commit.authors + .map((a) => `[${escapeMarkdown(a.name)}](mailto:${a.email})`) + .join(":")}`; + grouppedCommitsByDate[key] ??= []; + grouppedCommitsByDate[key].push(commit); + } + + for (const key in grouppedCommitsByDate) { + const [date, author] = key.split("::"); + output += `### ${date} - [${author}]\n\n`; + + for (const commit of grouppedCommitsByDate[key]) { + const conventionalCommitType = commit.subject.match( + /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|release|deps)(\(.+\))?:/, + ); + let needsBreakingChangeWarning = conventionalCommitType?.[0] + ?.trim() + ?.endsWith("!:"); + + if (conventionalCommitType) { + output += ` * **${conventionalCommitType[0]}**${commit.subject.slice(conventionalCommitType[0].length)}`; + } else { + output += ` * ${commit.subject}`; + } + + if (commit.body) { + output += " \n"; + + for (const line of commit.body.split("\n")) { + const colonIndex = line.indexOf(":"); + + if (colonIndex !== -1) { + const name = line.slice(0, colonIndex); + const value = line.slice(colonIndex + 1); + output += ` **${name}:** ${value} \n`; + + if (name === "BREAKING CHANGE") { + needsBreakingChangeWarning = false; + } + } else { + output += ` ${line} \n`; + } + } + } + + if (needsBreakingChangeWarning) { + output += " **This is a breaking change.** \n"; + output += " \n"; + } + + if (!commit.body && !needsBreakingChangeWarning) { + output += "\n"; + } + } + + output += "\n"; + } + + return output.trimEnd(); +} + function generateChangelog(commits) { let output = ""; - + const grouppedCommitsByDate = {}; - + for (const commit of commits) { - const key = `${commit.createdAt.getUTCDate().toString().padStart(2, 0)}-${commit.createdAt.getUTCMonth().toString().padStart(2, '0')}-${commit.createdAt.getUTCFullYear()}::${Array.isArray(commit.author) ? commit.author.join(':') : commit.author}`; + const key = `${commit.date.getUTCDate().toString().padStart(2, 0)}-${( + commit.date.getUTCMonth() + 1 + ) + .toString() + .padStart( + 2, + "0", + )}-${commit.date.getUTCFullYear()}::${commit.authors.map((a) => `${a.name} <${a.email}>`).join(":")}`; grouppedCommitsByDate[key] ??= []; grouppedCommitsByDate[key].push(commit); } @@ -165,13 +339,18 @@ const date = key.slice(0, separatorPosition); const commits = grouppedCommitsByDate[key]; - output += `${date} ${Array.isArray(commits[0].author) ? commits[0].author.join(", ") : commits[0].author}\n\n`; + output += `${date} ${commits[0].authors + .map((a) => `${a.name} <${a.email}>`) + .join(", ")}\n\n`; for (const commit of commits) { - output += ` ${commit.message.replaceAll("\n", "\n ")}\n\n`; + output += ` [*] ${commit.subject}\n${commit.body + .split("\n") + .map((part) => ` ${part}`) + .join("\n")}\n\n`; } } - + return output.trim(); } @@ -183,19 +362,26 @@ console.log("Options:"); console.log(" -h, --help Show this help and exit."); console.log(" -v, --version Show this script's version."); - console.log(" -o, --output=[FILE] Write the generated changelog to"); + console.log(" -f, --format Set the changelog format."); + console.log(" Supported formats are: plain,"); + console.log(" markdown."); + console.log(" -o, --output= Write the generated changelog to"); console.log(" a file instead of standard output."); console.log(" --no-overwrite Disallow overwriting of the output"); console.log(" file if it exists already."); console.log(); console.log("Send general inquiries, questions and bug reports"); - console.log("to ."); + console.log("to ."); } function printVersion() { console.log("Copyright (C) 2024 OSN, Inc."); - console.log("License GPLv3+: GNU GPL version 3 or later ."); - console.log("This is free software: you are free to change and redistribute it."); + console.log( + "License GPLv3+: GNU GPL version 3 or later .", + ); + console.log( + "This is free software: you are free to change and redistribute it.", + ); console.log("There is NO WARRANTY, to the extent permitted by law."); console.log(); console.log("Written by Ar Rakin."); @@ -212,23 +398,26 @@ options: { help: { type: "boolean", - alias: 'h' + alias: "h", }, version: { type: "boolean", - alias: "v" + alias: "v", }, output: { type: "string", - short: "o" + short: "o", }, "no-overwrite": { - type: "boolean" - } - } + type: "boolean", + }, + format: { + type: "string", + short: "f", + }, + }, }).values; - } - catch (error) { + } catch (error) { perror(`${error?.message ?? error}`); exit(1); } @@ -243,33 +432,57 @@ exit(0); } + if (options.format && !["markdown", "plain"].includes(options.format)) { + perror( + "option `--format` or `-f` only accepts one of the following: markdown, plain", + ); + exit(1); + } + if (!options.output && options["no-overwrite"]) { - perror("option `--no-overwrite' without `--output` does not make sense"); + perror( + "option `--no-overwrite' without `--output` does not make sense", + ); exit(1); } - - if (options.output && options["no-overwrite"] && existsSync(options.output)) { + + if ( + options.output && + options["no-overwrite"] && + existsSync(options.output) + ) { perror(`${options.output}: cannot write changelog: File exists`); exit(1); } - + const gitPath = checkForGit(); - const gitLog = getGitLog(gitPath); + const gitLog = await getGitLog(gitPath); const commits = parseGitLog(gitLog); - const changelog = generateChangelog(commits); + const filteredCommits = commits.filter( + (commit) => + !/Merge pull request #\d+ from|Merge branch '\S+' of/.test( + commit.message, + ), + ); + const changelog = + options.format === "markdown" + ? generateMarkdownChangelog(filteredCommits) + : generateChangelog(filteredCommits); - if (options.output) { + if (options.output) { try { await writeFile(options.output, changelog); - } - catch (error) { - perror(`${options.output}: failed to write changelog: ${error?.message ?? error}`); + } catch (error) { + perror( + `${options.output}: failed to write changelog: ${ + error?.message ?? error + }`, + ); exit(1); } print(`wrote generated changelog to ${options.output}`); - } - else { + } else { console.log(changelog); } }