From eefb586172b04df6f720b94b21214b65947fd681 Mon Sep 17 00:00:00 2001 From: Gion Kunz Date: Fri, 27 Sep 2024 14:45:23 +0200 Subject: [PATCH] fix(release): Support path offset from git root to Nx workspace, fixes #27995 --- .../release-version/release-version.ts | 11 +++- .../nx/src/command-line/release/changelog.ts | 39 +++++++++++---- .../nx/src/command-line/release/utils/git.ts | 48 ++++++++++++++---- .../release/utils/resolve-semver-specifier.ts | 8 ++- .../command-line/release/utils/shared.spec.ts | 50 ++++++++++++++++++- .../src/command-line/release/utils/shared.ts | 14 ++++++ 6 files changed, 147 insertions(+), 23 deletions(-) diff --git a/packages/js/src/generators/release-version/release-version.ts b/packages/js/src/generators/release-version/release-version.ts index 4e3b366cf378c..cd74034302e05 100644 --- a/packages/js/src/generators/release-version/release-version.ts +++ b/packages/js/src/generators/release-version/release-version.ts @@ -20,6 +20,7 @@ import { ProjectsVersionPlan, } from 'nx/src/command-line/release/config/version-plans'; import { + getAbsoluteGitRoot, getFirstGitCommit, getLatestGitTagForPattern, } from 'nx/src/command-line/release/utils/git'; @@ -34,6 +35,7 @@ import { deriveNewSemverVersion, validReleaseVersionPrefixes, } from 'nx/src/command-line/release/version'; +import { getWorkspaceGitRootOffset } from 'nx/src/command-line/release/utils/shared'; import { interpolate } from 'nx/src/tasks-runner/utils'; import * as ora from 'ora'; import { ReleaseType, gt, inc, prerelease } from 'semver'; @@ -407,11 +409,18 @@ To fix this you will either need to add a package.json file at that location, or ); } + // Obtain path offset from git root to Nx workspace for later normalizations + const workspaceGitRootOffset = getWorkspaceGitRootOffset( + await getAbsoluteGitRoot(), + workspaceRoot + ); + specifier = await resolveSemverSpecifierFromConventionalCommits( previousVersionRef, options.projectGraph, affectedProjects, - options.conventionalCommitsConfig + options.conventionalCommitsConfig, + workspaceGitRootOffset ); if (!specifier) { diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 6f23caf9b7c7c..667e507990d1b 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -48,6 +48,7 @@ import { import { GitCommit, Reference, + getAbsoluteGitRoot, getCommitHash, getFirstGitCommit, getGitDiff, @@ -71,6 +72,7 @@ import { commitChanges, createCommitMessageValues, createGitTagValues, + getWorkspaceGitRootOffset, handleDuplicateGitTags, noDiffInChangelogMessage, } from './utils/shared'; @@ -274,6 +276,12 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { // If there are multiple release groups, we'll just skip the workspace changelog anyway. const versionPlansEnabledForWorkspaceChangelog = releaseGroups[0].resolvedVersionPlans; + // Obtain path offset from git root to Nx workspace for later normalizations + const workspaceGitRootOffset = getWorkspaceGitRootOffset( + await getAbsoluteGitRoot(), + workspaceRoot + ); + if (versionPlansEnabledForWorkspaceChangelog) { if (releaseGroups.length === 1) { const releaseGroup = releaseGroups[0]; @@ -288,7 +296,7 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { let githubReferences = []; let author = undefined; const parsedCommit = vp.commit - ? parseGitCommit(vp.commit, true) + ? parseGitCommit(vp.commit, { isVersionPlanCommit: true }) : null; if (parsedCommit) { githubReferences = parsedCommit.references; @@ -350,7 +358,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { workspaceChangelogCommits = await getCommits( workspaceChangelogFromSHA, - toSHA + toSHA, + workspaceGitRootOffset ); workspaceChangelogChanges = filterHiddenChanges( @@ -517,7 +526,7 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { let githubReferences = []; let author = undefined; const parsedCommit = vp.commit - ? parseGitCommit(vp.commit, true) + ? parseGitCommit(vp.commit, { isVersionPlanCommit: true }) : null; if (parsedCommit) { githubReferences = parsedCommit.references; @@ -550,7 +559,11 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { if (!fromRef && useAutomaticFromRef) { const firstCommit = await getFirstGitCommit(); - const allCommits = await getCommits(firstCommit, toSHA); + const allCommits = await getCommits( + firstCommit, + toSHA, + workspaceGitRootOffset + ); const commitsForProject = allCommits.filter((c) => c.affectedFiles.find((f) => f.startsWith(project.data.root)) ); @@ -571,7 +584,11 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { } if (!commits) { - commits = await getCommits(fromRef, toSHA); + commits = await getCommits( + fromRef, + toSHA, + workspaceGitRootOffset + ); } const { fileMap } = await createFileMapUsingProjectGraph( @@ -673,7 +690,7 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { let githubReferences = []; let author = undefined; const parsedCommit = vp.commit - ? parseGitCommit(vp.commit, true) + ? parseGitCommit(vp.commit, { isVersionPlanCommit: true }) : null; if (parsedCommit) { githubReferences = parsedCommit.references; @@ -736,7 +753,7 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { fileMap.projectFileMap ); - commits = await getCommits(fromSHA, toSHA); + commits = await getCommits(fromSHA, toSHA, workspaceGitRootOffset); changes = filterHiddenChanges( commits.map((c) => ({ type: c.type, @@ -1366,11 +1383,15 @@ function checkChangelogFilesEnabled(nxReleaseConfig: NxReleaseConfig): boolean { async function getCommits( fromSHA: string, - toSHA: string + toSHA: string, + gitRootToWorkspacePath?: string ): Promise { const rawCommits = await getGitDiff(fromSHA, toSHA); // Parse as conventional commits - return parseCommits(rawCommits); + return parseCommits(rawCommits, { + isVersionPlanCommit: false, + gitRootToWorkspacePath, + }); } function filterHiddenChanges( diff --git a/packages/nx/src/command-line/release/utils/git.ts b/packages/nx/src/command-line/release/utils/git.ts index 1ce8acf7e184e..566ac324de101 100644 --- a/packages/nx/src/command-line/release/utils/git.ts +++ b/packages/nx/src/command-line/release/utils/git.ts @@ -391,8 +391,13 @@ export async function gitPush({ } } -export function parseCommits(commits: RawGitCommit[]): GitCommit[] { - return commits.map((commit) => parseGitCommit(commit)).filter(Boolean); +export function parseCommits( + commits: RawGitCommit[], + options: ParseGitCommitOptions = {} +): GitCommit[] { + return commits + .map((commit) => parseGitCommit(commit, options)) + .filter(Boolean); } export function parseConventionalCommitsMessage(message: string): { @@ -453,12 +458,17 @@ const IssueRE = /(#\d+)/gm; const ChangedFileRegex = /(A|M|D|R\d*|C\d*)\t([^\t\n]*)\t?(.*)?/gm; const RevertHashRE = /This reverts commit (?[\da-f]{40})./gm; +export interface ParseGitCommitOptions { + readonly isVersionPlanCommit?: boolean; + readonly gitRootToWorkspacePath?: string; +} + export function parseGitCommit( commit: RawGitCommit, - isVersionPlanCommit = false + options: ParseGitCommitOptions = {} ): GitCommit | null { // For version plans, we do not require conventional commits and therefore cannot extract data based on that format - if (isVersionPlanCommit) { + if (options.isVersionPlanCommit) { return { ...commit, description: commit.message, @@ -513,15 +523,25 @@ export function parseGitCommit( // Find all authors const authors = getAllAuthorsForCommit(commit); + const replaceGitRootPathDiff = (file: string) => { + if ( + !options.gitRootToWorkspacePath || + !file.startsWith(options.gitRootToWorkspacePath) + ) { + return file; + } + + return file.replace(options.gitRootToWorkspacePath, ''); + }; + // Extract file changes from commit body const affectedFiles = Array.from( commit.body.matchAll(ChangedFileRegex) - ).reduce( - (prev, [fullLine, changeType, file1, file2]: RegExpExecArray) => - // file2 only exists for some change types, such as renames - file2 ? [...prev, file1, file2] : [...prev, file1], - [] as string[] - ); + ).reduce((prev, [fullLine, changeType, file1, file2]: RegExpExecArray) => { + // file2 only exists for some change types, such as renames + const files = file2 ? [file1, file2] : [file1]; + return [...prev, ...files.map((file) => replaceGitRootPathDiff(file))]; + }, [] as string[]); return { ...commit, @@ -536,6 +556,14 @@ export function parseGitCommit( }; } +export async function getAbsoluteGitRoot() { + try { + return (await execCommand('git', ['rev-parse', '--show-toplevel'])).trim(); + } catch (e) { + throw new Error(`Could not determine git root`); + } +} + export async function getCommitHash(ref: string) { try { return (await execCommand('git', ['rev-parse', ref])).trim(); diff --git a/packages/nx/src/command-line/release/utils/resolve-semver-specifier.ts b/packages/nx/src/command-line/release/utils/resolve-semver-specifier.ts index 84e8ba28e27ca..27e7b3854d1e6 100644 --- a/packages/nx/src/command-line/release/utils/resolve-semver-specifier.ts +++ b/packages/nx/src/command-line/release/utils/resolve-semver-specifier.ts @@ -10,10 +10,14 @@ export async function resolveSemverSpecifierFromConventionalCommits( from: string, projectGraph: ProjectGraph, projectNames: string[], - conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] + conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'], + gitRootToWorkspacePath?: string ): Promise { const commits = await getGitDiff(from); - const parsedCommits = parseCommits(commits); + const parsedCommits = parseCommits(commits, { + isVersionPlanCommit: false, + gitRootToWorkspacePath, + }); const relevantCommits = await getCommitsRelevantToProjects( projectGraph, parsedCommits, diff --git a/packages/nx/src/command-line/release/utils/shared.spec.ts b/packages/nx/src/command-line/release/utils/shared.spec.ts index f95a12a5762b2..7609fd36c536c 100644 --- a/packages/nx/src/command-line/release/utils/shared.spec.ts +++ b/packages/nx/src/command-line/release/utils/shared.spec.ts @@ -1,5 +1,9 @@ import { ReleaseGroupWithName } from '../config/filter-release-groups'; -import { createCommitMessageValues, createGitTagValues } from './shared'; +import { + createCommitMessageValues, + createGitTagValues, + getWorkspaceGitRootOffset, +} from './shared'; describe('shared', () => { describe('createCommitMessageValues()', () => { @@ -278,4 +282,48 @@ describe('shared', () => { return { releaseGroup, releaseGroupToFilteredProjects }; } }); + + describe('getWorkspaceGitRootOffset', () => { + it('should return undefined when gitRoot and workspaceRoot are the same', () => { + const gitRoot = '/home/test/project'; + const workspaceRoot = '/home/test/project'; + const result = getWorkspaceGitRootOffset(gitRoot, workspaceRoot, '/'); + expect(result).toBeUndefined(); + }); + + it('should return the correct offset when workspaceRoot has a subdirectory', () => { + const gitRoot = '/home/test/project'; + const workspaceRoot = '/home/test/project/workspace'; + const result = getWorkspaceGitRootOffset(gitRoot, workspaceRoot, '/'); + expect(result).toBe('workspace/'); + }); + + it('should return undefined when gitRoot and workspaceRoot are the same in a Windows path', () => { + const gitRoot = 'C:\\home\\test\\project'; + const workspaceRoot = 'C:\\home\\test\\project'; + const result = getWorkspaceGitRootOffset(gitRoot, workspaceRoot, '\\'); + expect(result).toBeUndefined(); + }); + + it('should return the correct offset when workspaceRoot has a subdirectory in a Windows path', () => { + const gitRoot = 'C:\\home\\test\\project'; + const workspaceRoot = 'C:\\home\\test\\project\\workspace'; + const result = getWorkspaceGitRootOffset(gitRoot, workspaceRoot, '\\'); + expect(result).toBe('workspace\\'); + }); + + it('should handle complex subdirectory structures correctly', () => { + const gitRoot = '/home/test/project'; + const workspaceRoot = '/home/test/project/workspace/subdir'; + const result = getWorkspaceGitRootOffset(gitRoot, workspaceRoot, '/'); + expect(result).toBe('workspace/subdir/'); + }); + + it('should handle complex subdirectory structures in Windows paths correctly', () => { + const gitRoot = 'C:\\home\\test\\project'; + const workspaceRoot = 'C:\\home\\test\\project\\workspace\\subdir'; + const result = getWorkspaceGitRootOffset(gitRoot, workspaceRoot, '\\'); + expect(result).toBe('workspace\\subdir\\'); + }); + }); }); diff --git a/packages/nx/src/command-line/release/utils/shared.ts b/packages/nx/src/command-line/release/utils/shared.ts index 1eb598482cc79..e4cb74896402f 100644 --- a/packages/nx/src/command-line/release/utils/shared.ts +++ b/packages/nx/src/command-line/release/utils/shared.ts @@ -1,4 +1,5 @@ import * as chalk from 'chalk'; +import * as path from 'path'; import { prerelease } from 'semver'; import { ProjectGraph } from '../../../config/project-graph'; import { Tree } from '../../../generators/tree'; @@ -337,3 +338,16 @@ export async function getCommitsRelevantToProjects( ) ); } + +export function getWorkspaceGitRootOffset( + gitRoot: string, + workspaceRoot: string, + separator = path.sep +): string | undefined { + // Slice the gitRoot part from workspaceRoot to get the relative path + const offset = workspaceRoot.slice(gitRoot.length); + // Ensure there is a valid relative path and return it with the appropriate separator + return offset.startsWith(separator) && offset.length > 1 + ? offset.slice(1) + separator + : undefined; +}