diff --git a/src/fs.ts b/src/fs.ts index c3695077..acbcf6c7 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -5,6 +5,12 @@ import { } from '@metamask/action-utils'; import { wrapError, isErrorWithCode } from './misc-utils'; +/** + * Represents a writeable stream, such as that represented by `process.stdout` + * or `process.stderr`, or a fake one provided in tests. + */ +export type WriteStreamLike = Pick; + /** * Reads the file at the given path, assuming its content is encoded as UTF-8. * diff --git a/src/initial-parameters.test.ts b/src/initial-parameters.test.ts index c4459b0a..6c226856 100644 --- a/src/initial-parameters.test.ts +++ b/src/initial-parameters.test.ts @@ -1,7 +1,11 @@ import os from 'os'; import path from 'path'; import { when } from 'jest-when'; -import { buildMockProject, buildMockPackage } from '../tests/unit/helpers'; +import { + buildMockProject, + buildMockPackage, + createNoopWriteStream, +} from '../tests/unit/helpers'; import { determineInitialParameters } from './initial-parameters'; import * as commandLineArgumentsModule from './command-line-arguments'; import * as envModule from './env'; @@ -23,6 +27,7 @@ describe('initial-parameters', () => { it('returns an object derived from command-line arguments and environment variables that contains data necessary to run the workflow', async () => { const project = buildMockProject(); + const stderr = createNoopWriteStream(); when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) .calledWith(['arg1', 'arg2']) .mockResolvedValue({ @@ -34,13 +39,14 @@ describe('initial-parameters', () => { .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: undefined }); when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project') + .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters( - ['arg1', 'arg2'], - '/path/to/somewhere', - ); + const config = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, + }); expect(config).toStrictEqual({ project, @@ -53,6 +59,7 @@ describe('initial-parameters', () => { const project = buildMockProject({ rootPackage: buildMockPackage(), }); + const stderr = createNoopWriteStream(); when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) .calledWith(['arg1', 'arg2']) .mockResolvedValue({ @@ -67,13 +74,20 @@ describe('initial-parameters', () => { .spyOn(projectModule, 'readProject') .mockResolvedValue(project); - await determineInitialParameters(['arg1', 'arg2'], '/path/to/cwd'); + await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/cwd', + stderr, + }); - expect(readProjectSpy).toHaveBeenCalledWith('/path/to/cwd/project'); + expect(readProjectSpy).toHaveBeenCalledWith('/path/to/cwd/project', { + stderr, + }); }); it('resolves the given temporary directory relative to the current working directory', async () => { const project = buildMockProject(); + const stderr = createNoopWriteStream(); when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) .calledWith(['arg1', 'arg2']) .mockResolvedValue({ @@ -85,13 +99,14 @@ describe('initial-parameters', () => { .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: undefined }); when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project') + .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters( - ['arg1', 'arg2'], - '/path/to/cwd', - ); + const config = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/cwd', + stderr, + }); expect(config.tempDirectoryPath).toStrictEqual('/path/to/cwd/tmp'); }); @@ -100,6 +115,7 @@ describe('initial-parameters', () => { const project = buildMockProject({ rootPackage: buildMockPackage('@foo/bar'), }); + const stderr = createNoopWriteStream(); when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) .calledWith(['arg1', 'arg2']) .mockResolvedValue({ @@ -111,13 +127,14 @@ describe('initial-parameters', () => { .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: undefined }); when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project') + .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters( - ['arg1', 'arg2'], - '/path/to/cwd', - ); + const config = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/cwd', + stderr, + }); expect(config.tempDirectoryPath).toStrictEqual( path.join(os.tmpdir(), 'create-release-branch', '@foo__bar'), @@ -126,6 +143,7 @@ describe('initial-parameters', () => { it('returns initial parameters including reset: true, derived from a command-line argument of "--reset true"', async () => { const project = buildMockProject(); + const stderr = createNoopWriteStream(); when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) .calledWith(['arg1', 'arg2']) .mockResolvedValue({ @@ -137,19 +155,21 @@ describe('initial-parameters', () => { .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: undefined }); when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project') + .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters( - ['arg1', 'arg2'], - '/path/to/somewhere', - ); + const config = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, + }); expect(config.reset).toBe(true); }); it('returns initial parameters including reset: false, derived from a command-line argument of "--reset false"', async () => { const project = buildMockProject(); + const stderr = createNoopWriteStream(); when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) .calledWith(['arg1', 'arg2']) .mockResolvedValue({ @@ -161,13 +181,14 @@ describe('initial-parameters', () => { .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: undefined }); when(jest.spyOn(projectModule, 'readProject')) - .calledWith('/path/to/project') + .calledWith('/path/to/project', { stderr }) .mockResolvedValue(project); - const config = await determineInitialParameters( - ['arg1', 'arg2'], - '/path/to/somewhere', - ); + const config = await determineInitialParameters({ + argv: ['arg1', 'arg2'], + cwd: '/path/to/somewhere', + stderr, + }); expect(config.reset).toBe(false); }); diff --git a/src/initial-parameters.ts b/src/initial-parameters.ts index f945931e..ae82a952 100644 --- a/src/initial-parameters.ts +++ b/src/initial-parameters.ts @@ -1,6 +1,7 @@ import os from 'os'; import path from 'path'; import { readCommandLineArguments } from './command-line-arguments'; +import { WriteStreamLike } from './fs'; import { readProject, Project } from './project'; interface InitialParameters { @@ -13,18 +14,25 @@ interface InitialParameters { * Reads the inputs given to this tool via `process.argv` and uses them to * gather information about the project the tool can use to run. * - * @param argv - The arguments to this executable. - * @param cwd - The directory in which this executable was run. + * @param args - The arguments to this function. + * @param args.argv - The arguments to this executable. + * @param args.cwd - The directory in which this executable was run. + * @param args.stderr - A stream that can be used to write to standard error. * @returns The initial parameters. */ -export async function determineInitialParameters( - argv: string[], - cwd: string, -): Promise { +export async function determineInitialParameters({ + argv, + cwd, + stderr, +}: { + argv: string[]; + cwd: string; + stderr: WriteStreamLike; +}): Promise { const inputs = await readCommandLineArguments(argv); const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory); - const project = await readProject(projectDirectoryPath); + const project = await readProject(projectDirectoryPath, { stderr }); const tempDirectoryPath = inputs.tempDirectory === undefined ? path.join( diff --git a/src/main.ts b/src/main.ts index 66a0fe76..e1a2568e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,7 +26,7 @@ export async function main({ stderr: Pick; }) { const { project, tempDirectoryPath, reset } = - await determineInitialParameters(argv, cwd); + await determineInitialParameters({ argv, cwd, stderr }); if (project.isMonorepo) { stdout.write( diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts index 88effc1d..0d47836d 100644 --- a/src/misc-utils.test.ts +++ b/src/misc-utils.test.ts @@ -6,8 +6,9 @@ import { isErrorWithStack, wrapError, resolveExecutable, - getStdoutFromCommand, runCommand, + getStdoutFromCommand, + getLinesFromCommand, } from './misc-utils'; jest.mock('which'); @@ -135,6 +136,24 @@ describe('misc-utils', () => { }); }); + describe('runCommand', () => { + it('runs the command, discarding its output', async () => { + const execaSpy = jest + .spyOn(execaModule, 'default') + // Typecast: It's difficult to provide a full return value for execa + .mockResolvedValue({ stdout: ' some output ' } as any); + + const result = await runCommand('some command', ['arg1', 'arg2'], { + all: true, + }); + + expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], { + all: true, + }); + expect(result).toBeUndefined(); + }); + }); + describe('getStdoutFromCommand', () => { it('executes the given command and returns a version of the standard out from the command with whitespace trimmed', async () => { const execaSpy = jest @@ -155,21 +174,43 @@ describe('misc-utils', () => { }); }); - describe('runCommand', () => { - it('runs the command, discarding its output', async () => { + describe('getLinesFromCommand', () => { + it('executes the given command and returns the standard out from the command split into lines', async () => { const execaSpy = jest .spyOn(execaModule, 'default') // Typecast: It's difficult to provide a full return value for execa - .mockResolvedValue({ stdout: ' some output ' } as any); + .mockResolvedValue({ stdout: 'line 1\nline 2\nline 3' } as any); - const result = await runCommand('some command', ['arg1', 'arg2'], { + const lines = await getLinesFromCommand( + 'some command', + ['arg1', 'arg2'], + { all: true }, + ); + + expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], { all: true, }); + expect(lines).toStrictEqual(['line 1', 'line 2', 'line 3']); + }); + + it('does not strip leading and trailing whitespace from the output, but does remove empty lines', async () => { + const execaSpy = jest + .spyOn(execaModule, 'default') + // Typecast: It's difficult to provide a full return value for execa + .mockResolvedValue({ + stdout: ' line 1\nline 2\n\n line 3 \n', + } as any); + + const lines = await getLinesFromCommand( + 'some command', + ['arg1', 'arg2'], + { all: true }, + ); expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], { all: true, }); - expect(result).toBeUndefined(); + expect(lines).toStrictEqual([' line 1', 'line 2', ' line 3 ']); }); }); }); diff --git a/src/misc-utils.ts b/src/misc-utils.ts index 779ea052..14e5367c 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -118,6 +118,23 @@ export async function resolveExecutable( } } +/** + * Runs a command, discarding its output. + * + * @param command - The command to execute. + * @param args - The positional arguments to the command. + * @param options - The options to `execa`. + * @throws An `execa` error object if the command fails in some way. + * @see `execa`. + */ +export async function runCommand( + command: string, + args?: readonly string[] | undefined, + options?: execa.Options | undefined, +): Promise { + await execa(command, args, options); +} + /** * Runs a command, retrieving the standard output with leading and trailing * whitespace removed. @@ -138,18 +155,20 @@ export async function getStdoutFromCommand( } /** - * Runs a command, discarding its output. + * Runs a Git command, splitting up the immediate output into lines. * * @param command - The command to execute. * @param args - The positional arguments to the command. * @param options - The options to `execa`. + * @returns The standard output of the command. * @throws An `execa` error object if the command fails in some way. * @see `execa`. */ -export async function runCommand( +export async function getLinesFromCommand( command: string, args?: readonly string[] | undefined, options?: execa.Options | undefined, -): Promise { - await execa(command, args, options); +): Promise { + const { stdout } = await execa(command, args, options); + return stdout.split('\n').filter((value) => value !== ''); } diff --git a/src/package.test.ts b/src/package.test.ts index 6117b9db..906ffe1b 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -2,42 +2,423 @@ import fs from 'fs'; import path from 'path'; import { when } from 'jest-when'; import * as autoChangelog from '@metamask/auto-changelog'; +import { SemVer } from 'semver'; +import { MockWritable } from 'stdio-mock'; import { withSandbox } from '../tests/helpers'; import { buildMockPackage, buildMockProject, buildMockManifest, + createNoopWriteStream, } from '../tests/unit/helpers'; +import { + readMonorepoRootPackage, + readMonorepoWorkspacePackage, + updatePackage, +} from './package'; import * as fsModule from './fs'; -import { readPackage, updatePackage } from './package'; import * as packageManifestModule from './package-manifest'; +import * as repoModule from './repo'; jest.mock('@metamask/auto-changelog'); jest.mock('./package-manifest'); +jest.mock('./repo'); describe('package', () => { - describe('readPackage', () => { - it('reads information about the package located at the given directory', async () => { - const packageDirectoryPath = '/path/to/package'; + describe('readMonorepoRootPackage', () => { + it('returns information about the file structure of the package located at the given directory', async () => { + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + const pkg = await readMonorepoRootPackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: [], + }); + + expect(pkg).toMatchObject({ + directoryPath: '/path/to/package', + manifestPath: '/path/to/package/package.json', + changelogPath: '/path/to/package/CHANGELOG.md', + }); + }); + + it('returns information about the manifest (in both unvalidated and validated forms)', async () => { const unvalidatedManifest = {}; const validatedManifest = buildMockManifest(); + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/package/package.json') + .mockResolvedValue({ + unvalidated: unvalidatedManifest, + validated: validatedManifest, + }); + + const pkg = await readMonorepoRootPackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: [], + }); + + expect(pkg).toMatchObject({ + unvalidatedManifest, + validatedManifest, + }); + }); + + it("flags the package as having been changed since its latest release if a tag matching the current version exists and changes have been made to the package's directory since the tag", async () => { jest .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + version: new SemVer('1.0.0'), + }), + }); + when(jest.spyOn(repoModule, 'hasChangesInDirectorySinceGitTag')) + .calledWith('/path/to/project', '/path/to/package', 'v1.0.0') + .mockResolvedValue(true); + + const pkg = await readMonorepoRootPackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['v1.0.0'], + }); + + expect(pkg).toMatchObject({ + hasChangesSinceLatestRelease: true, + }); + }); + + it("does not flag the package as having been changed since its latest release if a tag matching the current version exists, but changes have not been made to the package's directory since the tag", async () => { + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + version: new SemVer('1.0.0'), + }), + }); + when(jest.spyOn(repoModule, 'hasChangesInDirectorySinceGitTag')) + .calledWith('/path/to/project', '/path/to/package', 'v1.0.0') + .mockResolvedValue(false); + + const pkg = await readMonorepoRootPackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['v1.0.0'], + }); + + expect(pkg).toMatchObject({ + hasChangesSinceLatestRelease: false, + }); + }); + + it('flags the package as having been changed since its latest release if a tag matching the current version does not exist', async () => { + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + version: new SemVer('1.0.0'), + }), + }); + + const pkg = await readMonorepoRootPackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: [], + }); + + expect(pkg).toMatchObject({ + hasChangesSinceLatestRelease: true, + }); + }); + + it('throws if a tag matching the current version does not exist', async () => { + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@scope/workspace-package', + version: new SemVer('1.0.0'), + }), + }); + when(jest.spyOn(repoModule, 'hasChangesInDirectorySinceGitTag')) + .calledWith('/path/to/project', '/path/to/package', 'v1.0.0') + .mockResolvedValue(true); + + const promiseForPkg = readMonorepoRootPackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['some-tag'], + }); + + await expect(promiseForPkg).rejects.toThrow( + new Error( + 'The package @scope/workspace-package has no Git tag for its current version 1.0.0 (expected "v1.0.0"), so this tool is unable to determine whether it should be included in this release. You will need to create a tag for this package in order to proceed.', + ), + ); + }); + }); + + describe('readMonorepoWorkspacePackage', () => { + it('returns information about the file structure of the package located at the given directory', async () => { + const stderr = createNoopWriteStream(); + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest(), + }); + + const pkg = await readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: [], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); + + expect(pkg).toMatchObject({ + directoryPath: '/path/to/package', + manifestPath: '/path/to/package/package.json', + changelogPath: '/path/to/package/CHANGELOG.md', + }); + }); + + it('returns information about the manifest (in both unvalidated and validated forms)', async () => { + const unvalidatedManifest = {}; + const validatedManifest = buildMockManifest(); + const stderr = createNoopWriteStream(); + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/package/package.json') .mockResolvedValue({ unvalidated: unvalidatedManifest, validated: validatedManifest, }); - const pkg = await readPackage(packageDirectoryPath); + const pkg = await readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: [], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); - expect(pkg).toStrictEqual({ - directoryPath: packageDirectoryPath, - manifestPath: path.join(packageDirectoryPath, 'package.json'), + expect(pkg).toMatchObject({ unvalidatedManifest, validatedManifest, - changelogPath: path.join(packageDirectoryPath, 'CHANGELOG.md'), }); }); + + it("flags the package as having been changed since its latest release if a tag matching the package name + version exists and changes have been made to the package's directory since the tag", async () => { + const stderr = createNoopWriteStream(); + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@scope/workspace-package', + version: new SemVer('1.0.0'), + }), + }); + when(jest.spyOn(repoModule, 'hasChangesInDirectorySinceGitTag')) + .calledWith( + '/path/to/project', + '/path/to/package', + '@scope/workspace-package@1.0.0', + ) + .mockResolvedValue(true); + + const pkg = await readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['@scope/workspace-package@1.0.0'], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); + + expect(pkg).toMatchObject({ + hasChangesSinceLatestRelease: true, + }); + }); + + it("does not flag the package as having been changed since its latest release if a tag matching the package name + version exists, but changes have not been made to the package's directory since the tag", async () => { + const stderr = createNoopWriteStream(); + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@scope/workspace-package', + version: new SemVer('1.0.0'), + }), + }); + when(jest.spyOn(repoModule, 'hasChangesInDirectorySinceGitTag')) + .calledWith( + '/path/to/project', + '/path/to/package', + '@scope/workspace-package@1.0.0', + ) + .mockResolvedValue(false); + + const pkg = await readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['@scope/workspace-package@1.0.0'], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); + + expect(pkg).toMatchObject({ + hasChangesSinceLatestRelease: false, + }); + }); + + it("flags the package as having been changed since its latest release if a tag matching 'v' + the root package version exists instead of the package name + version, and changes have been made to the package's directory since the tag", async () => { + const stderr = createNoopWriteStream(); + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@scope/workspace-package', + version: new SemVer('1.0.0'), + }), + }); + when(jest.spyOn(repoModule, 'hasChangesInDirectorySinceGitTag')) + .calledWith('/path/to/project', '/path/to/package', 'v5.0.0') + .mockResolvedValue(true); + + const pkg = await readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['v5.0.0'], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); + + expect(pkg).toMatchObject({ + hasChangesSinceLatestRelease: true, + }); + }); + + it("does not flag the package as having been changed since its latest release if a tag matching 'v' + the root package version exists instead of the package name + version, but changes have not been made to the package's directory since the tag", async () => { + const stderr = createNoopWriteStream(); + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + version: new SemVer('1.0.0'), + }), + }); + when(jest.spyOn(repoModule, 'hasChangesInDirectorySinceGitTag')) + .calledWith('/path/to/project', '/path/to/package', 'v5.0.0') + .mockResolvedValue(false); + + const pkg = await readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['v5.0.0'], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); + + expect(pkg).toMatchObject({ + hasChangesSinceLatestRelease: false, + }); + }); + + it('flags the package as having been changed since its latest release if the project has no tags', async () => { + const stderr = createNoopWriteStream(); + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + version: new SemVer('1.0.0'), + }), + }); + + const pkg = await readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: [], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); + + expect(pkg).toMatchObject({ + hasChangesSinceLatestRelease: true, + }); + }); + + it("prints a warning if a tag matching 'v' + the root package version exists instead of the package name + version", async () => { + const stderr = new MockWritable(); + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/package/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@scope/workspace-package', + version: new SemVer('1.0.0'), + }), + }); + + await readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['v5.0.0'], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); + + expect(stderr.data()).toStrictEqual([ + 'WARNING: Could not determine changes for workspace package @scope/workspace-package version 1.0.0 based on Git tag "@scope/workspace-package@1.0.0"; using tag for root package root-package version 5.0.0, "v5.0.0", instead.\n', + ]); + }); + + it("throws if the project has tags, but neither a tag matching the package name + version nor 'v' + the root package version exists", async () => { + const stderr = createNoopWriteStream(); + when(jest.spyOn(packageManifestModule, 'readPackageManifest')) + .calledWith('/path/to/package/package.json') + .mockResolvedValue({ + unvalidated: {}, + validated: buildMockManifest({ + name: '@scope/workspace-package', + version: new SemVer('1.0.0'), + }), + }); + + const promise = readMonorepoWorkspacePackage({ + packageDirectoryPath: '/path/to/package', + projectDirectoryPath: '/path/to/project', + projectTagNames: ['some-tag'], + rootPackageName: 'root-package', + rootPackageVersion: new SemVer('5.0.0'), + stderr, + }); + + await expect(promise).rejects.toThrow( + new Error( + 'The current release of workspace package @scope/workspace-package, 1.0.0, has no corresponding Git tag "@scope/workspace-package@1.0.0", and the current release of root package root-package, 5.0.0, has no tag "v5.0.0". Hence, this tool is unable to know whether the workspace package changed and should be included in this release. You will need to create tags for both of these packages in order to proceed.', + ), + ); + }); }); describe('updatePackage', () => { diff --git a/src/package.ts b/src/package.ts index 0b5a201c..7e2ccca0 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,8 +1,9 @@ import fs, { WriteStream } from 'fs'; import path from 'path'; +import { format } from 'util'; import { updateChangelog } from '@metamask/auto-changelog'; +import { WriteStreamLike, readFile, writeFile, writeJsonFile } from './fs'; import { isErrorWithCode } from './misc-utils'; -import { readFile, writeFile, writeJsonFile } from './fs'; import { readPackageManifest, UnvalidatedPackageManifest, @@ -10,6 +11,8 @@ import { } from './package-manifest'; import { Project } from './project'; import { PackageReleasePlan } from './release-plan'; +import { hasChangesInDirectorySinceGitTag } from './repo'; +import { SemVer } from './semver'; const MANIFEST_FILE_NAME = 'package.json'; const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; @@ -30,21 +33,191 @@ export interface Package { unvalidatedManifest: UnvalidatedPackageManifest; validatedManifest: ValidatedPackageManifest; changelogPath: string; + hasChangesSinceLatestRelease: boolean; } /** - * Collects information about a package. + * Generates the possible Git tag name for the root package of a monorepo. The + * only tag name in use at this time is "v" + the package version. * - * @param packageDirectoryPath - The path to a package within a project. + * @param packageVersion - The version of the package. + * @returns An array of possible release tag names. + */ +function generateMonorepoRootPackageReleaseTagName(packageVersion: string) { + return `v${packageVersion}`; +} + +/** + * Generates a possible Git tag name for the workspace package of a monorepo. + * Accounts for changes to `action-publish-release`, which going forward will + * generate tags for workspace packages in `PACKAGE_NAME@MAJOR.MINOR.PATCH`. + * + * @param packageName - The name of the package. + * @param packageVersion - The version of the package. + * @returns An array of possible release tag names. + */ +function generateMonorepoWorkspacePackageReleaseTagName( + packageName: string, + packageVersion: string, +) { + return `${packageName}@${packageVersion}`; +} + +/** + * Collects information about the root package of a monorepo. + * + * @param args - The arguments to this function. + * @param args.packageDirectoryPath - The path to a package within a project. + * @param args.projectDirectoryPath - The path to the project directory. + * @param args.projectTagNames - The tag names across the whole project. + * @returns Information about the package. + */ +export async function readMonorepoRootPackage({ + packageDirectoryPath, + projectDirectoryPath, + projectTagNames, +}: { + packageDirectoryPath: string; + projectDirectoryPath: string; + projectTagNames: string[]; +}): Promise { + const manifestPath = path.join(packageDirectoryPath, MANIFEST_FILE_NAME); + const changelogPath = path.join(packageDirectoryPath, CHANGELOG_FILE_NAME); + const { unvalidated: unvalidatedManifest, validated: validatedManifest } = + await readPackageManifest(manifestPath); + const expectedTagNameForLatestRelease = + generateMonorepoRootPackageReleaseTagName( + validatedManifest.version.toString(), + ); + const matchingTagNameForLatestRelease = projectTagNames.find( + (tagName) => tagName === expectedTagNameForLatestRelease, + ); + + if ( + projectTagNames.length > 0 && + matchingTagNameForLatestRelease === undefined + ) { + throw new Error( + format( + 'The package %s has no Git tag for its current version %s (expected %s), so this tool is unable to determine whether it should be included in this release. You will need to create a tag for this package in order to proceed.', + validatedManifest.name, + validatedManifest.version, + `"${expectedTagNameForLatestRelease}"`, + ), + ); + } + + const hasChangesSinceLatestRelease = + matchingTagNameForLatestRelease === undefined + ? true + : await hasChangesInDirectorySinceGitTag( + projectDirectoryPath, + packageDirectoryPath, + expectedTagNameForLatestRelease, + ); + + return { + directoryPath: packageDirectoryPath, + manifestPath, + validatedManifest, + unvalidatedManifest, + changelogPath, + hasChangesSinceLatestRelease, + }; +} + +/** + * Collects information about a workspace package within a monorepo. + * + * @param args - The arguments to this function. + * @param args.packageDirectoryPath - The path to a package within a project. + * @param args.rootPackageName - The name of the root package within the + * monorepo to which this package belongs. + * @param args.rootPackageVersion - The version of the root package within the + * monorepo to which this package belongs. + * @param args.projectDirectoryPath - The path to the project directory. + * @param args.projectTagNames - The tag names across the whole project. + * @param args.stderr - A stream that can be used to write to standard error. * @returns Information about the package. */ -export async function readPackage( - packageDirectoryPath: string, -): Promise { +export async function readMonorepoWorkspacePackage({ + packageDirectoryPath, + rootPackageName, + rootPackageVersion, + projectDirectoryPath, + projectTagNames, + stderr, +}: { + packageDirectoryPath: string; + rootPackageName: string; + rootPackageVersion: SemVer; + projectDirectoryPath: string; + projectTagNames: string[]; + stderr: WriteStreamLike; +}): Promise { const manifestPath = path.join(packageDirectoryPath, MANIFEST_FILE_NAME); const changelogPath = path.join(packageDirectoryPath, CHANGELOG_FILE_NAME); const { unvalidated: unvalidatedManifest, validated: validatedManifest } = await readPackageManifest(manifestPath); + const expectedTagNameForWorkspacePackageLatestRelease = + generateMonorepoWorkspacePackageReleaseTagName( + validatedManifest.name, + validatedManifest.version.toString(), + ); + const expectedTagNameForRootPackageLatestRelease = + generateMonorepoRootPackageReleaseTagName(rootPackageVersion.toString()); + const matchingTagNameForWorkspacePackageLatestRelease = projectTagNames.find( + (tagName) => tagName === expectedTagNameForWorkspacePackageLatestRelease, + ); + const matchingTagNameForRootPackageLatestRelease = projectTagNames.find( + (tagName) => tagName === expectedTagNameForRootPackageLatestRelease, + ); + const matchingTagNameForLatestRelease = + matchingTagNameForWorkspacePackageLatestRelease ?? + matchingTagNameForRootPackageLatestRelease; + + if ( + projectTagNames.length > 0 && + matchingTagNameForLatestRelease === undefined + ) { + throw new Error( + format( + 'The current release of workspace package %s, %s, has no corresponding Git tag %s, and the current release of root package %s, %s, has no tag %s. Hence, this tool is unable to know whether the workspace package changed and should be included in this release. You will need to create tags for both of these packages in order to proceed.', + validatedManifest.name, + validatedManifest.version, + `"${expectedTagNameForWorkspacePackageLatestRelease}"`, + rootPackageName, + rootPackageVersion, + `"${expectedTagNameForRootPackageLatestRelease}"`, + ), + ); + } + + if ( + matchingTagNameForWorkspacePackageLatestRelease === undefined && + matchingTagNameForRootPackageLatestRelease !== undefined + ) { + stderr.write( + format( + 'WARNING: Could not determine changes for workspace package %s version %s based on Git tag %s; using tag for root package %s version %s, %s, instead.\n', + validatedManifest.name, + validatedManifest.version, + `"${expectedTagNameForWorkspacePackageLatestRelease}"`, + rootPackageName, + rootPackageVersion, + `"${expectedTagNameForRootPackageLatestRelease}"`, + ), + ); + } + + const hasChangesSinceLatestRelease = + matchingTagNameForLatestRelease === undefined + ? true + : await hasChangesInDirectorySinceGitTag( + projectDirectoryPath, + packageDirectoryPath, + matchingTagNameForLatestRelease, + ); return { directoryPath: packageDirectoryPath, @@ -52,6 +225,7 @@ export async function readPackage( validatedManifest, unvalidatedManifest, changelogPath, + hasChangesSinceLatestRelease, }; } diff --git a/src/project.test.ts b/src/project.test.ts index 2701efdf..205066f7 100644 --- a/src/project.test.ts +++ b/src/project.test.ts @@ -1,8 +1,9 @@ import fs from 'fs'; import path from 'path'; import { when } from 'jest-when'; +import { SemVer } from 'semver'; import { withSandbox } from '../tests/helpers'; -import { buildMockManifest, buildMockPackage } from '../tests/unit/helpers'; +import { buildMockPackage, createNoopWriteStream } from '../tests/unit/helpers'; import { readProject } from './project'; import * as packageModule from './package'; import * as repoModule from './repo'; @@ -16,16 +17,21 @@ describe('project', () => { await withSandbox(async (sandbox) => { const projectDirectoryPath = sandbox.directoryPath; const projectRepositoryUrl = 'https://github.com/some-org/some-repo'; - const rootPackage = buildMockPackage('root', '4.38.0', { - directoryPath: projectDirectoryPath, - validatedManifest: buildMockManifest({ - workspaces: ['packages/a', 'packages/subpackages/*'], - }), - }); + const rootPackageName = 'root'; + const rootPackageVersion = new SemVer('4.38.0'); + const rootPackage = buildMockPackage( + rootPackageName, + rootPackageVersion, + { + directoryPath: projectDirectoryPath, + validatedManifest: { + workspaces: ['packages/a', 'packages/subpackages/*'], + }, + }, + ); const workspacePackages = { a: buildMockPackage('a', { directoryPath: path.join(projectDirectoryPath, 'packages', 'a'), - validatedManifest: buildMockManifest(), }), b: buildMockPackage('b', { directoryPath: path.join( @@ -34,20 +40,50 @@ describe('project', () => { 'subpackages', 'b', ), - validatedManifest: buildMockManifest(), }), }; + const projectTagNames = ['tag1', 'tag2', 'tag3']; + const stderr = createNoopWriteStream(); when(jest.spyOn(repoModule, 'getRepositoryHttpsUrl')) .calledWith(projectDirectoryPath) .mockResolvedValue(projectRepositoryUrl); - when(jest.spyOn(packageModule, 'readPackage')) + when(jest.spyOn(repoModule, 'getTagNames')) .calledWith(projectDirectoryPath) - .mockResolvedValue(rootPackage) - .calledWith(path.join(projectDirectoryPath, 'packages', 'a')) + .mockResolvedValue(projectTagNames); + when(jest.spyOn(packageModule, 'readMonorepoRootPackage')) + .calledWith({ + packageDirectoryPath: projectDirectoryPath, + projectDirectoryPath, + projectTagNames, + }) + .mockResolvedValue(rootPackage); + when(jest.spyOn(packageModule, 'readMonorepoWorkspacePackage')) + .calledWith({ + packageDirectoryPath: path.join( + projectDirectoryPath, + 'packages', + 'a', + ), + rootPackageName, + rootPackageVersion, + projectDirectoryPath, + projectTagNames, + stderr, + }) .mockResolvedValue(workspacePackages.a) - .calledWith( - path.join(projectDirectoryPath, 'packages', 'subpackages', 'b'), - ) + .calledWith({ + packageDirectoryPath: path.join( + projectDirectoryPath, + 'packages', + 'subpackages', + 'b', + ), + rootPackageName, + rootPackageVersion, + projectDirectoryPath, + projectTagNames, + stderr, + }) .mockResolvedValue(workspacePackages.b); await fs.promises.mkdir(path.join(projectDirectoryPath, 'packages')); await fs.promises.mkdir( @@ -60,7 +96,9 @@ describe('project', () => { path.join(projectDirectoryPath, 'packages', 'subpackages', 'b'), ); - expect(await readProject(projectDirectoryPath)).toStrictEqual({ + expect( + await readProject(projectDirectoryPath, { stderr }), + ).toStrictEqual({ directoryPath: projectDirectoryPath, repositoryUrl: projectRepositoryUrl, rootPackage, diff --git a/src/project.ts b/src/project.ts index 1d22ec4f..e0bb9198 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,8 +1,13 @@ import util from 'util'; import glob from 'glob'; -import { Package, readPackage } from './package'; +import { WriteStreamLike } from './fs'; +import { + Package, + readMonorepoRootPackage, + readMonorepoWorkspacePackage, +} from './package'; import { PackageManifestFieldNames } from './package-manifest'; -import { getRepositoryHttpsUrl } from './repo'; +import { getRepositoryHttpsUrl, getTagNames } from './repo'; import { SemVer } from './semver'; /** @@ -65,11 +70,12 @@ function examineReleaseVersion(packageVersion: SemVer): ReleaseVersion { } /** - * Collects information about a project. For a polyrepo, this information will - * only cover the project's `package.json` file; for a monorepo, it will cover - * `package.json` files for any workspaces that the monorepo defines. + * Collects information about a monorepo — its root package as well as any + * packages within workspaces specified via the root `package.json`. * * @param projectDirectoryPath - The path to the project. + * @param args - Additional arguments. + * @param args.stderr - A stream that can be used to write to standard error. * @returns An object that represents information about the project. * @throws if the project does not contain a root `package.json` (polyrepo and * monorepo) or if any of the workspaces specified in the root `package.json` do @@ -77,9 +83,15 @@ function examineReleaseVersion(packageVersion: SemVer): ReleaseVersion { */ export async function readProject( projectDirectoryPath: string, + { stderr }: { stderr: WriteStreamLike }, ): Promise { const repositoryUrl = await getRepositoryHttpsUrl(projectDirectoryPath); - const rootPackage = await readPackage(projectDirectoryPath); + const tagNames = await getTagNames(projectDirectoryPath); + const rootPackage = await readMonorepoRootPackage({ + packageDirectoryPath: projectDirectoryPath, + projectDirectoryPath, + projectTagNames: tagNames, + }); const releaseVersion = examineReleaseVersion( rootPackage.validatedManifest.version, ); @@ -100,7 +112,14 @@ export async function readProject( const workspacePackages = ( await Promise.all( workspaceDirectories.map(async (directory) => { - return await readPackage(directory); + return await readMonorepoWorkspacePackage({ + packageDirectoryPath: directory, + rootPackageName: rootPackage.validatedManifest.name, + rootPackageVersion: rootPackage.validatedManifest.version, + projectDirectoryPath, + projectTagNames: tagNames, + stderr, + }); }), ) ).reduce((obj, pkg) => { diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts index 113ce583..79138a2e 100644 --- a/src/release-specification.test.ts +++ b/src/release-specification.test.ts @@ -22,12 +22,19 @@ jest.mock('./misc-utils', () => { describe('release-specification', () => { describe('generateReleaseSpecificationTemplateForMonorepo', () => { - it('returns a YAML-encoded string which has a list of all workspace packages in the project', async () => { + it('returns a YAML-encoded string which has a list of all workspace packages in the project which have been changed since their latest releases', async () => { const project = buildMockProject({ rootPackage: buildMockPackage('monorepo'), workspacePackages: { - a: buildMockPackage('a'), - b: buildMockPackage('b'), + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: false, + }), + c: buildMockPackage('c', { + hasChangesSinceLatestRelease: true, + }), }, }); @@ -53,17 +60,44 @@ describe('release-specification', () => { packages: a: null - b: null + c: null `.slice(1), ); }); + it('throws if no packages have been changed', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: false, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: false, + }), + }, + }); + + await expect( + generateReleaseSpecificationTemplateForMonorepo({ + project, + isEditorAvailable: false, + }), + ).rejects.toThrow( + 'Could not generate release specification: There are no packages that have changed since their latest release.', + ); + }); + it('adjusts the instructions slightly if an editor is not available', async () => { const project = buildMockProject({ rootPackage: buildMockPackage('monorepo'), workspacePackages: { - a: buildMockPackage('a'), - b: buildMockPackage('b'), + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + }), }, }); diff --git a/src/release-specification.ts b/src/release-specification.ts index 1be5469b..976672ee 100644 --- a/src/release-specification.ts +++ b/src/release-specification.ts @@ -79,7 +79,17 @@ export async function generateReleaseSpecificationTemplateForMonorepo({ ${afterEditingInstructions} `.trim(); - const packages = Object.values(workspacePackages).reduce((obj, pkg) => { + const changedWorkspacePackages = Object.values(workspacePackages).filter( + (pkg) => pkg.hasChangesSinceLatestRelease, + ); + + if (changedWorkspacePackages.length === 0) { + throw new Error( + 'Could not generate release specification: There are no packages that have changed since their latest release.', + ); + } + + const packages = changedWorkspacePackages.reduce((obj, pkg) => { return { ...obj, [pkg.validatedManifest.name]: null }; }, {}); diff --git a/src/repo.test.ts b/src/repo.test.ts index ffeb5b09..aad954d5 100644 --- a/src/repo.test.ts +++ b/src/repo.test.ts @@ -1,29 +1,15 @@ import { when } from 'jest-when'; import { - getStdoutFromGitCommandWithin, getRepositoryHttpsUrl, captureChangesInReleaseBranch, + getTagNames, + hasChangesInDirectorySinceGitTag, } from './repo'; import * as miscUtils from './misc-utils'; jest.mock('./misc-utils'); -describe('git-utils', () => { - describe('getStdoutFromGitCommandWithin', () => { - it('calls getStdoutFromCommand with "git" as the command, passing the given args and using the given directory as the working directory', async () => { - when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) - .calledWith('git', ['foo', 'bar'], { cwd: '/path/to/repo' }) - .mockResolvedValue('the output'); - - const output = await getStdoutFromGitCommandWithin('/path/to/repo', [ - 'foo', - 'bar', - ]); - - expect(output).toStrictEqual('the output'); - }); - }); - +describe('repo', () => { describe('getRepositoryHttpsUrl', () => { it('returns the URL of the "origin" remote of the given repo if it looks like a HTTPS public GitHub repo URL', async () => { const repositoryDirectoryPath = '/path/to/project'; @@ -117,4 +103,125 @@ describe('git-utils', () => { ); }); }); + + describe('getTagNames', () => { + it('returns all of the tag names that match a known format, sorted by ascending semantic version order', async () => { + when(jest.spyOn(miscUtils, 'getLinesFromCommand')) + .calledWith('git', ['tag', '--sort=version:refname', '--merged'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue(['tag1', 'tag2', 'tag3']); + + expect(await getTagNames('/path/to/repo')).toStrictEqual([ + 'tag1', + 'tag2', + 'tag3', + ]); + }); + + it('returns an empty array if the repo has no tags as long as it was not cloned shallowly', async () => { + when(jest.spyOn(miscUtils, 'getLinesFromCommand')) + .calledWith('git', ['tag', '--sort=version:refname', '--merged'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue([]); + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['rev-parse', '--is-shallow-repository'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue('false'); + + expect(await getTagNames('/path/to/repo')).toStrictEqual([]); + }); + + it('throws if the repo has no tags but it was cloned shallowly', async () => { + when(jest.spyOn(miscUtils, 'getLinesFromCommand')) + .calledWith('git', ['tag', '--sort=version:refname', '--merged'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue([]); + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['rev-parse', '--is-shallow-repository'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue('true'); + + await expect(getTagNames('/path/to/repo')).rejects.toThrow( + '"git tag" returned no tags. Increase your git fetch depth.', + ); + }); + + it('throws if "git rev-parse --is-shallow-repository" returns neither "true" nor "false"', async () => { + when(jest.spyOn(miscUtils, 'getLinesFromCommand')) + .calledWith('git', ['tag', '--sort=version:refname', '--merged'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue([]); + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['rev-parse', '--is-shallow-repository'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue('something-else'); + + await expect(getTagNames('/path/to/repo')).rejects.toThrow( + '"git rev-parse --is-shallow-repository" returned unrecognized value: "something-else"', + ); + }); + }); + + describe('hasChangesInDirectorySinceGitTag', () => { + it('returns true if "git diff" includes any files within the given directory, for the first call', async () => { + when(jest.spyOn(miscUtils, 'getLinesFromCommand')) + .calledWith('git', ['diff', 'v1.0.0', 'HEAD', '--name-only'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue([ + '/path/to/repo/file1', + '/path/to/repo/subdirectory/file1', + ]); + + const hasChanges = await hasChangesInDirectorySinceGitTag( + '/path/to/repo', + '/path/to/repo/subdirectory', + 'v1.0.0', + ); + + expect(hasChanges).toBe(true); + }); + + it('returns false if "git diff" does not include any files within the given directory, for the first call', async () => { + when(jest.spyOn(miscUtils, 'getLinesFromCommand')) + .calledWith('git', ['diff', 'v2.0.0', 'HEAD', '--name-only'], { + cwd: '/path/to/repo', + }) + .mockResolvedValue(['/path/to/repo/file1', '/path/to/repo/file2']); + + const hasChanges = await hasChangesInDirectorySinceGitTag( + '/path/to/repo', + '/path/to/repo/subdirectory', + 'v2.0.0', + ); + + expect(hasChanges).toBe(false); + }); + + it('only runs "git diff" once when called more than once for the same tag name (even for a different subdirectory)', async () => { + const getLinesFromCommandSpy = jest + .spyOn(miscUtils, 'getLinesFromCommand') + .mockResolvedValue([]); + + await hasChangesInDirectorySinceGitTag( + '/path/to/repo', + '/path/to/repo/subdirectory1', + 'v3.0.0', + ); + await hasChangesInDirectorySinceGitTag( + '/path/to/repo', + '/path/to/repo/subdirectory2', + 'v3.0.0', + ); + + expect(getLinesFromCommandSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/repo.ts b/src/repo.ts index bd5fdcfb..a2488a33 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -1,20 +1,28 @@ -import { getStdoutFromCommand } from './misc-utils'; +import { + runCommand, + getStdoutFromCommand, + getLinesFromCommand, +} from './misc-utils'; + +const CHANGED_FILE_PATHS_BY_TAG_NAME: Record = {}; /** - * Runs a command within the given directory, obtaining the immediate output. + * Runs a Git command within the given repository, discarding its output. * - * @param directoryPath - The path to the directory. - * @param command - The command to execute. - * @param args - The positional arguments to the command. + * @param repositoryDirectoryPath - The path to the repository directory. + * @param commandName - The name of the Git command (e.g., "commit"). + * @param commandArgs - The arguments to the command. * @returns The standard output of the command. * @throws An execa error object if the command fails in some way. */ -async function getStdoutFromCommandWithin( - directoryPath: string, - command: string, - args?: readonly string[] | undefined, -): Promise { - return await getStdoutFromCommand(command, args, { cwd: directoryPath }); +async function runGitCommandWithin( + repositoryDirectoryPath: string, + commandName: string, + commandArgs: readonly string[], +): Promise { + await runCommand('git', [commandName, ...commandArgs], { + cwd: repositoryDirectoryPath, + }); } /** @@ -22,15 +30,93 @@ async function getStdoutFromCommandWithin( * output. * * @param repositoryDirectoryPath - The path to the repository directory. - * @param args - The arguments to the command. + * @param commandName - The name of the Git command (e.g., "commit"). + * @param commandArgs - The arguments to the command. * @returns The standard output of the command. * @throws An execa error object if the command fails in some way. */ -export async function getStdoutFromGitCommandWithin( +async function getStdoutFromGitCommandWithin( repositoryDirectoryPath: string, - args: readonly string[], -) { - return await getStdoutFromCommandWithin(repositoryDirectoryPath, 'git', args); + commandName: string, + commandArgs: readonly string[], +): Promise { + return await getStdoutFromCommand('git', [commandName, ...commandArgs], { + cwd: repositoryDirectoryPath, + }); +} + +/** + * Runs a Git command within the given repository, splitting up the immediate + * output into lines. + * + * @param repositoryDirectoryPath - The path to the repository directory. + * @param commandName - The name of the Git command (e.g., "commit"). + * @param commandArgs - The arguments to the command. + * @returns A set of lines from the standard output of the command. + * @throws An execa error object if the command fails in some way. + */ +async function getLinesFromGitCommandWithin( + repositoryDirectoryPath: string, + commandName: string, + commandArgs: readonly string[], +): Promise { + return await getLinesFromCommand('git', [commandName, ...commandArgs], { + cwd: repositoryDirectoryPath, + }); +} + +/** + * Check whether the local repository has a complete git history. + * Implemented using `git rev-parse --is-shallow-repository`. + * + * @param repositoryDirectoryPath - The path to the repository directory. + * @returns Whether the local repository has a complete, as opposed to shallow, + * git history. + * @throws if `git rev-parse --is-shallow-repository` returns an unrecognized + * value. + */ +async function hasCompleteGitHistory( + repositoryDirectoryPath: string, +): Promise { + const isShallow = await getStdoutFromGitCommandWithin( + repositoryDirectoryPath, + 'rev-parse', + ['--is-shallow-repository'], + ); + + // We invert the meaning of these strings because we want to know if the + // repository is NOT shallow. + if (isShallow === 'true') { + return false; + } else if (isShallow === 'false') { + return true; + } + + throw new Error( + `"git rev-parse --is-shallow-repository" returned unrecognized value: ${JSON.stringify( + isShallow, + )}`, + ); +} + +/** + * Performs a diff in order to obtains a set of files that were changed in the + * given repository between a particular tag and HEAD. + * + * @param repositoryDirectoryPath - The path to the repository directory. + * @param tagName - The name of the tag to compare against HEAD. + * @returns An array of paths to files that have changes between the given tag + * and HEAD. + */ +async function getFilesChangedSince( + repositoryDirectoryPath: string, + tagName: string, +): Promise { + return await getLinesFromGitCommandWithin(repositoryDirectoryPath, 'diff', [ + tagName, + 'HEAD', + '--name-only', + ]); } /** @@ -54,10 +140,10 @@ export async function getRepositoryHttpsUrl( const httpsPrefix = 'https://github.com'; const sshPrefixRegex = /^git@github\.com:/u; const sshPostfixRegex = /\.git$/u; - const gitConfigUrl = await getStdoutFromCommandWithin( + const gitConfigUrl = await getStdoutFromGitCommandWithin( repositoryDirectoryPath, - 'git', - ['config', '--get', 'remote.origin.url'], + 'config', + ['--get', 'remote.origin.url'], ); if (gitConfigUrl.startsWith(httpsPrefix)) { @@ -88,23 +174,90 @@ export async function getRepositoryHttpsUrl( * of the new release). * 3. Switches to that branch. * - * @param projectRepositoryPath - The path to the repository directory. + * @param repositoryDirectoryPath - The path to the repository directory. * @param args - The arguments. * @param args.releaseVersion - The release version. */ export async function captureChangesInReleaseBranch( - projectRepositoryPath: string, + repositoryDirectoryPath: string, { releaseVersion }: { releaseVersion: string }, ) { - await getStdoutFromGitCommandWithin(projectRepositoryPath, [ - 'checkout', + await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'checkout', [ '-b', `release/${releaseVersion}`, ]); - await getStdoutFromGitCommandWithin(projectRepositoryPath, ['add', '-A']); - await getStdoutFromGitCommandWithin(projectRepositoryPath, [ - 'commit', + await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'add', ['-A']); + await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'commit', [ '-m', `Release ${releaseVersion}`, ]); } + +/** + * Retrieves the names of the tags in the given repo, sorted by ascending + * semantic version order. As this fetches tags from the remote first, you are + * advised to only run this once. + * + * @param repositoryDirectoryPath - The path to the repository directory. + * @returns The names of the tags. + * @throws If no tags are found and the local git history is incomplete. + */ +export async function getTagNames( + repositoryDirectoryPath: string, +): Promise { + await runGitCommandWithin(repositoryDirectoryPath, 'fetch', ['--tags']); + + const tagNames = await getLinesFromGitCommandWithin( + repositoryDirectoryPath, + 'tag', + [ + '--sort=version:refname', + // The --merged flag ensures that we only get tags that are parents of or + // equal to the current HEAD. + '--merged', + ], + ); + + if ( + tagNames.length === 0 && + !(await hasCompleteGitHistory(repositoryDirectoryPath)) + ) { + throw new Error( + `"git tag" returned no tags. Increase your git fetch depth.`, + ); + } + + return tagNames; +} + +/** + * Calculates whether there have been any commits in the given repo since the + * given tag that include changes to any of the files within the given + * subdirectory within that repo. The result is cached so that multiple calls + * using the same tag name do not re-request the diff. + * + * @param repositoryDirectoryPath - The path to the repository directory. + * @param subdirectoryPath - The path to a subdirectory within the repository. + * @param tagName - The name of a tag in the repository. + * @returns True or false, depending on the result. + */ +export async function hasChangesInDirectorySinceGitTag( + repositoryDirectoryPath: string, + subdirectoryPath: string, + tagName: string, +): Promise { + if (!(tagName in CHANGED_FILE_PATHS_BY_TAG_NAME)) { + const changedFilePaths = await getFilesChangedSince( + repositoryDirectoryPath, + tagName, + ); + + if (!(tagName in CHANGED_FILE_PATHS_BY_TAG_NAME)) { + CHANGED_FILE_PATHS_BY_TAG_NAME[tagName] = changedFilePaths; + } + } + + return CHANGED_FILE_PATHS_BY_TAG_NAME[tagName].some((filePath) => { + return filePath.startsWith(subdirectoryPath); + }); +} diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index dfb5f050..ac2e0fcc 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import path from 'path'; import { SemVer } from 'semver'; import { isPlainObject } from '@metamask/utils'; @@ -21,7 +22,13 @@ type Unrequire = Omit & { }; type MockPackageOverrides = Omit< - Unrequire, + Unrequire< + Package, + | 'directoryPath' + | 'manifestPath' + | 'changelogPath' + | 'hasChangesSinceLatestRelease' + >, 'unvalidatedManifest' | 'validatedManifest' > & { validatedManifest?: Omit< @@ -98,6 +105,7 @@ export function buildMockPackage( directoryPath = `/path/to/packages/${name}`, manifestPath = path.join(directoryPath, 'package.json'), changelogPath = path.join(directoryPath, 'CHANGELOG.md'), + hasChangesSinceLatestRelease = false, } = overrides; return { @@ -111,6 +119,7 @@ export function buildMockPackage( }), manifestPath, changelogPath, + hasChangesSinceLatestRelease, }; } @@ -132,3 +141,13 @@ export function buildMockManifest( ...overrides, }; } + +/** + * Creates a write stream that discards all messages sent to it. This is useful + * as a default value for functions that need to take a `stdout` or `stderr`. + * + * @returns The write stream. + */ +export function createNoopWriteStream(): fs.WriteStream { + return fs.createWriteStream('/dev/null'); +}