#!/usr/bin/env node
/**
* Copyright (C) 2024 OSN, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
"use strict";
const path = require("path");
const { existsSync } = require("fs");
const { exit } = require("process");
const { execSync } = require("child_process");
const { writeFile } = require("fs/promises");
const { parseArgs } = require("util");
const ME = path.basename(process.argv[1]);
function print(...args) {
console.log(`${ME}: ${args.join(" ")}`);
}
function perror(...args) {
console.error(`\x1b[0m${ME}: error: ${args.join(" ")}`);
}
function findInPath(executable) {
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;
}
}
return null;
}
function checkForGit() {
const gitPath = findInPath("git");
if (!gitPath) {
perror("could not find git in $PATH");
perror("please make sure git is installed and available in $PATH");
exit(1);
}
return gitPath;
}
function getGitLog(gitPath) {
try {
return execSync(gitPath + " --no-pager log", { encoding: "utf8" });
}
catch {
perror("command `git --no-pager log' failed");
exit(1);
}
}
function parseGitLog(gitLog) {
const lines = gitLog.split("\n");
const commits = [];
for (let i = 0; i < lines.length; ) {
if (!lines[i].startsWith("commit")) {
i++;
continue;
}
const [, hash] = lines[i++].split(' ');
const headerProps = {};
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++;
}
const messageLines = [];
while (i < lines.length && !lines[i].startsWith("commit")) {
const lineToPush = lines[i++].replace(/^ /, "");
if (!lineToPush) {
continue;
}
messageLines.push(lineToPush);
}
let mindex = messageLines.length - 1;
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();
if (!validFooterProps.includes(name)) {
continue;
}
const value = messageLine.slice(colonIndex + 1).trim();
if (name in footerProps && !Array.isArray(footerProps[name])) {
footerProps[name] = [footerProps[name]];
}
if (Array.isArray(footerProps[name])) {
footerProps[name].push(value);
}
else {
footerProps[name] = value;
}
messageLines.splice(mindex - 1, 1);
}
const message = messageLines.join("\n");
commits.push({
hash,
message,
headerProps,
footerProps,
signedOffBy: footerProps["signed-off-by"],
onBehalfOf: footerProps["on-behalf-of"],
author: headerProps["author"],
createdAt: new Date(headerProps["date"])
});
}
return commits;
}
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}`;
grouppedCommitsByDate[key] ??= [];
grouppedCommitsByDate[key].push(commit);
}
for (const key in grouppedCommitsByDate) {
const separatorPosition = key.indexOf("::");
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`;
for (const commit of commits) {
output += ` ${commit.message.replaceAll("\n", "\n ")}\n\n`;
}
}
return output.trim();
}
function printHelp() {
console.log("Usage:");
console.log(` ${ME} [OPTION]...`);
console.log("Generate a formatted ChangeLog from Git commit logs.");
console.log();
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(" 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 .");
}
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("There is NO WARRANTY, to the extent permitted by law.");
console.log();
console.log("Written by Ar Rakin.");
}
async function main() {
let options;
try {
options = parseArgs({
argv: process.argv.slice(1),
strict: true,
allowPositionals: false,
options: {
help: {
type: "boolean",
alias: 'h'
},
version: {
type: "boolean",
alias: "v"
},
output: {
type: "string",
short: "o"
},
"no-overwrite": {
type: "boolean"
}
}
}).values;
}
catch (error) {
perror(`${error?.message ?? error}`);
exit(1);
}
if (options.help) {
printHelp();
exit(0);
}
if (options.version) {
printVersion();
exit(0);
}
if (!options.output && options["no-overwrite"]) {
perror("option `--no-overwrite' without `--output` does not make sense");
exit(1);
}
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 commits = parseGitLog(gitLog);
const changelog = generateChangelog(commits);
if (options.output) {
try {
await writeFile(options.output, changelog);
}
catch (error) {
perror(`${options.output}: failed to write changelog: ${error?.message ?? error}`);
exit(1);
}
print(`wrote generated changelog to ${options.output}`);
}
else {
console.log(changelog);
}
}
main();