diff --git a/lib/branches/get-tags.js b/lib/branches/get-tags.js index 019ce4cc..2c8c610f 100644 --- a/lib/branches/get-tags.js +++ b/lib/branches/get-tags.js @@ -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; }, [] diff --git a/lib/git.js b/lib/git.js index 979256cf..b6ffb46b 100644 --- a/lib/git.js +++ b/lib/git.js @@ -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>} 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", // eg. (tag: v24.2.3) {"channels":[null]} + `--notes=refs/notes/${GIT_NOTE_REF}*`, // handles both patterns for notes - `semantic-release` (old) and `semantic-release-` (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; } /** diff --git a/lib/utils.js b/lib/utils.js index 180c98f1..a7d87ac5 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -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; +} diff --git a/test/git.test.js b/test/git.test.js index 157081a3..9f384aa6 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -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) => { diff --git a/test/utils.test.js b/test/utils.test.js index aaaed41a..0761e787 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -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"]); +});