perf(get-tags.js): bulk get for tags notes (#3732)

Co-authored-by: Matt Travi <126441+travi@users.noreply.github.com>
This commit is contained in:
Tomáš Vojtášek
2025-07-11 21:33:04 +02:00
committed by GitHub
parent 672f951b95
commit 85187e2cb6
5 changed files with 92 additions and 43 deletions
+6 -2
View File
@@ -2,7 +2,7 @@ import { escapeRegExp, template } from "lodash-es";
import semver from "semver";
import pReduce from "p-reduce";
import debugTags from "debug";
import { getNote, getTags } from "../../lib/git.js";
import { getTags, getTagsNotes } from "../../lib/git.js";
const debug = debugTags("semantic-release:get-tags");
@@ -13,6 +13,9 @@ export default async ({ cwd, env, options: { tagFormat } }, branches) => {
// so it's guaranteed to no be present in the `tagFormat`.
const tagRegexp = `^${escapeRegExp(template(tagFormat)({ version: " " })).replace(" ", "(.+)")}`;
// Get the tags notes for all the tags in the repository
const tagsNotesMap = await getTagsNotes({ cwd, env });
return pReduce(
branches,
async (branches, branch) => {
@@ -20,8 +23,9 @@ export default async ({ cwd, env, options: { tagFormat } }, branches) => {
await getTags(branch.name, { cwd, env }),
async (branchTags, tag) => {
const [, version] = tag.match(tagRegexp) || [];
const channels = tagsNotesMap.has(tag) ? tagsNotesMap.get(tag).channels : [null];
return version && semver.valid(semver.clean(version))
? [...branchTags, { gitTag: tag, version, channels: (await getNote(tag, { cwd, env })).channels || [null] }]
? [...branchTags, { gitTag: tag, version, channels }]
: branchTags;
},
[]
+50 -29
View File
@@ -4,6 +4,7 @@ import { execa } from "execa";
import debugGit from "debug";
import { merge } from "lodash-es";
import { GIT_NOTE_REF } from "./definitions/constants.js";
import { extractGitLogTags } from "./utils.js";
const debug = debugGit("semantic-release:git");
@@ -296,42 +297,62 @@ export async function isBranchUpToDate(repositoryUrl, branch, execaOptions) {
}
/**
* Get and parse the JSON note of a given reference.
* Retrieves a map of Git tags to their associated notes from the repository.
*
* @param {String} ref The Git reference for which to retrieve the note.
* @param {Object} [execaOpts] Options to pass to `execa`.
* Executes a `git log` command to list tags and their notes, then parses the output into a Map
* where each key is a tag name (e.g., "v24.2.3") and the value is the parsed JSON note object.
*
* @return {Object} the parsed JSON note if there is one, an empty object otherwise.
* @async
* @param {import('execa').Options} execaOptions - Options to pass to `execa`
* @returns {Promise<Map<string, Object>>} A promise that resolves to a Map of tag names to their notes.
*/
export async function getNote(ref, execaOptions) {
const handleError = (error) => {
if (error.exitCode === 1) {
return { stdout: "{}" };
export async function getTagsNotes(execaOptions) {
/**
* git log --tags="*" --decorate-refs="refs/tags/*" --no-walk --format="%d%x09%N" --notes="refs/notes/semantic-release*"
*
* (tag: v1.2.3)
* (tag: v2.0.0) {"channels":[null]}
* (tag: v3.0.0, tag: 5833/merge) {"channels":[null]}
* ...
*/
const { stdout } = await execa(
"git",
[
"log",
"--tags=*",
"--decorate-refs=refs/tags/*", // This filters the refs shown in the %d format specifier to only include tags matching refs/tags/*.
"--no-walk", // This ensures that only the commits directly pointed to by the tags are shown, not their historical parents.
"--format=%d%x09%N", // <refName><tab><notes> eg. (tag: v24.2.3) {"channels":[null]}
`--notes=refs/notes/${GIT_NOTE_REF}*`, // handles both patterns for notes - `semantic-release` (old) and `semantic-release-<version>` (current)
],
execaOptions
);
// drop empty lines
const lines = stdout.split("\n").filter((line) => line.trim() !== "");
// parse and create a map of tags to notes
const tagNotesMap = new Map();
for (const line of lines) {
const [tagPart, notePart] = line.trim().split("\t"); // tab separator is defined in the git command above (%x09)
const tags = extractGitLogTags(tagPart);
if (tags.length === 0) {
debug(`Cannot parse tags from line: ${line}`);
continue;
}
debug(error);
throw error;
};
try {
return merge(
JSON.parse(
// Used for retro-compatibility
(await execa("git", ["notes", "--ref", GIT_NOTE_REF, "show", ref], execaOptions).catch(handleError)).stdout
),
JSON.parse(
(await execa("git", ["notes", "--ref", `${GIT_NOTE_REF}-${ref}`, "show", ref], execaOptions).catch(handleError))
.stdout
)
);
} catch (error) {
if (error.exitCode === 1) {
return {};
try {
const parsed = JSON.parse(notePart);
tags.forEach((tag) => {
tagNotesMap.set(tag, parsed);
});
} catch (error) {
debug(error);
}
debug(error);
throw error;
}
return tagNotesMap;
}
/**
+11 -1
View File
@@ -1,4 +1,4 @@
import { isFunction, template, union } from "lodash-es";
import { template, union } from "lodash-es";
import semver from "semver";
import hideSensitive from "./hide-sensitive.js";
@@ -81,3 +81,13 @@ export function makeTag(tagFormat, version) {
export function isSameChannel(channel, otherChannel) {
return channel === otherChannel || (!channel && !otherChannel);
}
export function extractGitLogTags(tagsString) {
const regex = /tag: ([^,)]+)/g;
let match;
const tags = [];
while ((match = regex.exec(tagsString)) !== null) {
tags.push(match[1]);
}
return tags;
}
+19 -11
View File
@@ -6,9 +6,9 @@ import {
fetchNotes,
getBranches,
getGitHead,
getNote,
getTagHead,
getTags,
getTagsNotes,
isBranchUpToDate,
isGitRepo,
isRefExists,
@@ -332,31 +332,39 @@ test("Get a commit note", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(["First"], { cwd });
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitAddNote(JSON.stringify({ note: "note" }), commits[0].hash, { cwd });
await gitAddNote(JSON.stringify({ note: "note" }), "v1.0.0", { cwd });
const tagsNotes = await getTagsNotes({ cwd });
t.deepEqual(await getNote(commits[0].hash, { cwd }), { note: "note" });
t.deepEqual(tagsNotes.get("v1.0.0"), { note: "note" });
});
test("Return empty object if there is no commit note", async (t) => {
test("Return undefined if there is no commit note", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(["First"], { cwd });
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
t.deepEqual(await getNote(commits[0].hash, { cwd }), {});
const tagsNotes = await getTagsNotes({ cwd });
t.deepEqual(tagsNotes.get("v1.0.0"), undefined);
});
test("Throw error if a commit note in invalid", async (t) => {
test("Return undefined if a commit note is invalid", async (t) => {
// Create a git repository, set the current working directory at the root of the repo
const { cwd } = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(["First"], { cwd });
await gitCommits(["First"], { cwd });
await gitTagVersion("v1.0.0", undefined, { cwd });
await gitAddNote("non-json note", commits[0].hash, { cwd });
await gitAddNote("non-json note", "v1.0.0", { cwd });
await t.throwsAsync(getNote(commits[0].hash, { cwd }));
const tagsNotes = await getTagsNotes({ cwd });
t.deepEqual(tagsNotes.get("v1.0.0"), undefined);
});
test("Add a commit note", async (t) => {
+6
View File
@@ -14,6 +14,7 @@ import {
isSameChannel,
lowest,
makeTag,
extractGitLogTags,
tagsToVersions,
} from "../lib/utils.js";
@@ -187,3 +188,8 @@ test("isSameChannel", (t) => {
t.false(isSameChannel("next", false));
});
test("extractGitLogTags", (t) => {
t.deepEqual(extractGitLogTags(`(tag: v1.2.3)`), ["v1.2.3"]);
t.deepEqual(extractGitLogTags(`(tag: v1.2.3, tag: 5833/merge)`), ["v1.2.3", "5833/merge"]);
});