/[osn-commons]/trunk/git/genchangelog
ViewVC logotype

Contents of /trunk/git/genchangelog

Parent Directory Parent Directory | Revision Log Revision Log


Revision 56 - (show annotations)
Fri Aug 30 11:55:09 2024 UTC (7 months ago) by rakinar2
File size: 14308 byte(s)
fix(git:genchangelog): git command fails due to reaching max memory buffer size
1 #!/usr/bin/env node
2
3 /**
4 * Copyright (C) 2024 OSN, Inc.
5 *
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20 "use strict";
21
22 const path = require("path");
23 const { existsSync } = require("fs");
24 const { exit } = require("process");
25 const { execSync, exec, spawn } = require("child_process");
26 const { writeFile } = require("fs/promises");
27 const { parseArgs } = require("util");
28 const crypto = require("crypto");
29
30 const ME = path.basename(process.argv[1]);
31 const GIT_COMMIT_BOUNDARY = crypto.randomBytes(64).toString("hex");
32 const GIT_SPACE_BOUNDARY = crypto.randomBytes(64).toString("hex");
33
34 function print(...args) {
35 console.log(`${ME}: ${args.join(" ")}`);
36 }
37
38 function perror(...args) {
39 console.error(`\x1b[0m${ME}: error: ${args.join(" ")}`);
40 }
41
42 function findInPath(executable) {
43 for (const segment of process.env.PATH?.split(
44 process.platform === "win32" ? ";" : ":",
45 ) ?? []) {
46 const executablePath = path.join(
47 segment,
48 executable + (process.platform === "win32" ? ".exe" : ""),
49 );
50
51 if (existsSync(executablePath)) {
52 return executablePath;
53 }
54 }
55
56 return null;
57 }
58
59 function checkForGit() {
60 const gitPath = findInPath("git");
61
62 if (!gitPath) {
63 perror("could not find git in $PATH");
64 perror("please make sure git is installed and available in $PATH");
65 exit(1);
66 }
67
68 return gitPath;
69 }
70
71 async function getGitLog(gitPath) {
72 try {
73 let output = "";
74
75 const child = spawn(
76 gitPath,
77 [
78 "--no-pager",
79 "log",
80 `--pretty=format:%h %H %an${GIT_SPACE_BOUNDARY} %ae %ad${GIT_SPACE_BOUNDARY} %B${GIT_COMMIT_BOUNDARY}`,
81 ],
82 { encoding: "utf8", stdio: "pipe" },
83 );
84
85 child.stdout.on("data", (data) => {
86 output += data;
87 });
88
89 child.stderr.on("data", (data) => {
90 console.error(data);
91 });
92
93 await new Promise((resolve) => {
94 child.on("close", (code) => {
95 if (code !== 0) {
96 perror("command `git log' failed with exit code " + code);
97 exit(1);
98 } else {
99 resolve();
100 }
101 });
102 });
103
104 return output;
105 } catch (error) {
106 console.error(error);
107 perror("command `git log' failed");
108 exit(1);
109 }
110 }
111
112 function strUntil(str, boundary) {
113 let output = "";
114 let i = 0;
115
116 for (i = 0; i < str.length; i++) {
117 if (str[i] === boundary[0]) {
118 let boundaryIndex = 0;
119 let previousI = i;
120
121 while (
122 boundaryIndex < boundary.length &&
123 i < str.length &&
124 str[i] === boundary[boundaryIndex]
125 ) {
126 i++;
127 boundaryIndex++;
128 }
129
130 if (boundaryIndex === boundary.length) {
131 return { output, size: i };
132 }
133
134 i = previousI;
135 }
136
137 output += str[i];
138 }
139
140 return { output, size: i };
141 }
142
143 function parseGitLog(gitLog) {
144 const commits = [];
145
146 let i = 0;
147
148 while (i < gitLog.length) {
149 const { output, size } = strUntil(gitLog.slice(i), GIT_COMMIT_BOUNDARY);
150 i += size;
151
152 let outputIndex = 0;
153
154 const shortIdSpaceIndex = output.indexOf(" ");
155 const idSpaceIndex = output.indexOf(" ", shortIdSpaceIndex + 1);
156 const shortId = output.slice(outputIndex, shortIdSpaceIndex);
157 const id = output.slice(shortIdSpaceIndex + 1, idSpaceIndex);
158
159 outputIndex += shortId.length + id.length + 2;
160
161 const { output: authorName, size: authorNameSize } = strUntil(
162 output.slice(outputIndex),
163 GIT_SPACE_BOUNDARY,
164 );
165
166 outputIndex += authorNameSize + 1;
167
168 const authorEmailSpaceIndex = output.indexOf(" ", outputIndex + 1);
169 const authorEmail = output.slice(outputIndex, authorEmailSpaceIndex);
170
171 outputIndex += authorEmail.length + 1;
172
173 const { output: date, size: dateSize } = strUntil(
174 output.slice(outputIndex),
175 GIT_SPACE_BOUNDARY,
176 );
177
178 outputIndex += dateSize + 1;
179
180 const message = output.slice(outputIndex);
181 const newlineIndex = message.indexOf("\n");
182 const subject = message.slice(
183 0,
184 newlineIndex === -1 ? undefined : newlineIndex,
185 );
186 const body = newlineIndex === -1 ? "" : message.slice(newlineIndex + 1);
187
188 const validFooterProps = [
189 "signed-off-by",
190 "co-authored-by",
191 "on-behalf-of",
192 ];
193 const footerProps = {};
194 const coAuthors = [];
195
196 for (const line of body.split("\n")) {
197 const colonIndex = line.indexOf(":");
198 const key = line.slice(0, colonIndex).trim().toLowerCase();
199 const value = line.slice(colonIndex + 1).trim();
200
201 if (validFooterProps.includes(key)) {
202 footerProps[key] = value;
203 }
204
205 if (key === "co-authored-by") {
206 const name = value.slice(0, value.lastIndexOf(" ")).trim();
207
208 coAuthors.push({
209 name,
210 email: value
211 .slice(name.length)
212 .trim()
213 .replace(/^<|>$/g, ""),
214 });
215 }
216 }
217
218 commits.push({
219 shortId,
220 id,
221 author: {
222 name: authorName,
223 email: authorEmail,
224 },
225 date: new Date(date),
226 subject,
227 body,
228 footerProps,
229 message,
230 coAuthors,
231 authors: [
232 {
233 name: authorName,
234 email: authorEmail,
235 },
236 ...coAuthors,
237 ],
238 });
239 }
240
241 return commits;
242 }
243
244 function escapeMarkdown(str) {
245 return str.replace(/([_*~`])/g, "\\$1");
246 }
247
248 function generateMarkdownChangelog(commits) {
249 let output = "# Changelog\n\n";
250
251 const grouppedCommitsByDate = {};
252
253 for (const commit of commits) {
254 const key = `${commit.date.getUTCDate().toString().padStart(2, 0)}-${(
255 commit.date.getUTCMonth() + 1
256 )
257 .toString()
258 .padStart(2, "0")}-${commit.date.getUTCFullYear()}::${commit.authors
259 .map((a) => `[${escapeMarkdown(a.name)}](mailto:${a.email})`)
260 .join(":")}`;
261 grouppedCommitsByDate[key] ??= [];
262 grouppedCommitsByDate[key].push(commit);
263 }
264
265 for (const key in grouppedCommitsByDate) {
266 const [date, author] = key.split("::");
267 output += `### ${date} - [${author}]\n\n`;
268
269 for (const commit of grouppedCommitsByDate[key]) {
270 const conventionalCommitType = commit.subject.match(
271 /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|release|deps)(\(.+\))?:/,
272 );
273 let needsBreakingChangeWarning = conventionalCommitType?.[0]
274 ?.trim()
275 ?.endsWith("!:");
276
277 if (conventionalCommitType) {
278 output += ` * **${conventionalCommitType[0]}**${commit.subject.slice(conventionalCommitType[0].length)}`;
279 } else {
280 output += ` * ${commit.subject}`;
281 }
282
283 if (commit.body) {
284 output += " \n";
285
286 for (const line of commit.body.split("\n")) {
287 const colonIndex = line.indexOf(":");
288
289 if (colonIndex !== -1) {
290 const name = line.slice(0, colonIndex);
291 const value = line.slice(colonIndex + 1);
292 output += ` **${name}:** ${value} \n`;
293
294 if (name === "BREAKING CHANGE") {
295 needsBreakingChangeWarning = false;
296 }
297 } else {
298 output += ` ${line} \n`;
299 }
300 }
301 }
302
303 if (needsBreakingChangeWarning) {
304 output += " **This is a breaking change.** \n";
305 output += " \n";
306 }
307
308 if (!commit.body && !needsBreakingChangeWarning) {
309 output += "\n";
310 }
311 }
312
313 output += "\n";
314 }
315
316 return output.trimEnd();
317 }
318
319 function generateChangelog(commits) {
320 let output = "";
321
322 const grouppedCommitsByDate = {};
323
324 for (const commit of commits) {
325 const key = `${commit.date.getUTCDate().toString().padStart(2, 0)}-${(
326 commit.date.getUTCMonth() + 1
327 )
328 .toString()
329 .padStart(
330 2,
331 "0",
332 )}-${commit.date.getUTCFullYear()}::${commit.authors.map((a) => `${a.name} <${a.email}>`).join(":")}`;
333 grouppedCommitsByDate[key] ??= [];
334 grouppedCommitsByDate[key].push(commit);
335 }
336
337 for (const key in grouppedCommitsByDate) {
338 const separatorPosition = key.indexOf("::");
339 const date = key.slice(0, separatorPosition);
340 const commits = grouppedCommitsByDate[key];
341
342 output += `${date} ${commits[0].authors
343 .map((a) => `${a.name} <${a.email}>`)
344 .join(", ")}\n\n`;
345
346 for (const commit of commits) {
347 output += ` [*] ${commit.subject}\n${commit.body
348 .split("\n")
349 .map((part) => ` ${part}`)
350 .join("\n")}\n\n`;
351 }
352 }
353
354 return output.trim();
355 }
356
357 function printHelp() {
358 console.log("Usage:");
359 console.log(` ${ME} [OPTION]...`);
360 console.log("Generate a formatted ChangeLog from Git commit logs.");
361 console.log();
362 console.log("Options:");
363 console.log(" -h, --help Show this help and exit.");
364 console.log(" -v, --version Show this script's version.");
365 console.log(" -f, --format Set the changelog format.");
366 console.log(" Supported formats are: plain,");
367 console.log(" markdown.");
368 console.log(" -o, --output=<FILE> Write the generated changelog to");
369 console.log(" a file instead of standard output.");
370 console.log(" --no-overwrite Disallow overwriting of the output");
371 console.log(" file if it exists already.");
372 console.log();
373 console.log("Send general inquiries, questions and bug reports");
374 console.log("to <[email protected]>.");
375 }
376
377 function printVersion() {
378 console.log("Copyright (C) 2024 OSN, Inc.");
379 console.log(
380 "License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.",
381 );
382 console.log(
383 "This is free software: you are free to change and redistribute it.",
384 );
385 console.log("There is NO WARRANTY, to the extent permitted by law.");
386 console.log();
387 console.log("Written by Ar Rakin.");
388 }
389
390 async function main() {
391 let options;
392
393 try {
394 options = parseArgs({
395 argv: process.argv.slice(1),
396 strict: true,
397 allowPositionals: false,
398 options: {
399 help: {
400 type: "boolean",
401 alias: "h",
402 },
403 version: {
404 type: "boolean",
405 alias: "v",
406 },
407 output: {
408 type: "string",
409 short: "o",
410 },
411 "no-overwrite": {
412 type: "boolean",
413 },
414 format: {
415 type: "string",
416 short: "f",
417 },
418 },
419 }).values;
420 } catch (error) {
421 perror(`${error?.message ?? error}`);
422 exit(1);
423 }
424
425 if (options.help) {
426 printHelp();
427 exit(0);
428 }
429
430 if (options.version) {
431 printVersion();
432 exit(0);
433 }
434
435 if (options.format && !["markdown", "plain"].includes(options.format)) {
436 perror(
437 "option `--format` or `-f` only accepts one of the following: markdown, plain",
438 );
439 exit(1);
440 }
441
442 if (!options.output && options["no-overwrite"]) {
443 perror(
444 "option `--no-overwrite' without `--output` does not make sense",
445 );
446 exit(1);
447 }
448
449 if (
450 options.output &&
451 options["no-overwrite"] &&
452 existsSync(options.output)
453 ) {
454 perror(`${options.output}: cannot write changelog: File exists`);
455 exit(1);
456 }
457
458 const gitPath = checkForGit();
459 const gitLog = await getGitLog(gitPath);
460 const commits = parseGitLog(gitLog);
461 const filteredCommits = commits.filter(
462 (commit) =>
463 !/Merge pull request #\d+ from|Merge branch '\S+' of/.test(
464 commit.message,
465 ),
466 );
467 const changelog =
468 options.format === "markdown"
469 ? generateMarkdownChangelog(filteredCommits)
470 : generateChangelog(filteredCommits);
471
472 if (options.output) {
473 try {
474 await writeFile(options.output, changelog);
475 } catch (error) {
476 perror(
477 `${options.output}: failed to write changelog: ${
478 error?.message ?? error
479 }`,
480 );
481 exit(1);
482 }
483
484 print(`wrote generated changelog to ${options.output}`);
485 } else {
486 console.log(changelog);
487 }
488 }
489
490 main();

Properties

Name Value
svn:executable *

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26