diff --git a/package.json b/package.json index 2f42081..570a672 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "jest-it-up": "^2.0.2", "jest-when": "^3.5.2", "nanoid": "^3.3.4", + "outdent": "^0.8.0", "prettier": "^2.2.1", "prettier-plugin-packagejson": "^2.3.0", "rimraf": "^4.0.5", diff --git a/src/command-line-arguments.ts b/src/command-line-arguments.ts index fecac70..028f98e 100644 --- a/src/command-line-arguments.ts +++ b/src/command-line-arguments.ts @@ -6,6 +6,7 @@ export type CommandLineArguments = { tempDirectory: string | undefined; reset: boolean; backport: boolean; + defaultBranch: string; }; /** @@ -44,6 +45,12 @@ export async function readCommandLineArguments( type: 'boolean', default: false, }) + .option('default-branch', { + alias: 'b', + describe: 'The name of the default branch in the repository.', + default: 'main', + type: 'string', + }) .help() .strict() .parse(); diff --git a/src/functional.test.ts b/src/functional.test.ts index ceb6aaf..8d26354 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -575,21 +575,26 @@ describe('create-release-branch (functional)', () => { }, }); - // Tests four things: - // * The latest commit should be called "Release 1.0.0" + // Tests five things: + // * The latest commit should be called "Update Release 2.0.0" + // * The before latest commit should be called "Initialize Release 2.0.0" // * The latest commit should be the current commit (HEAD) - // * The latest branch should be called "release/1.0.0" + // * The latest branch should be called "release/2.0.0" // * The latest branch should point to the latest commit - const [latestCommitSubject, latestCommitId, latestCommitRevsMarker] = - ( - await environment.runCommand('git', [ - 'log', - '--pretty=%s%x09%H%x09%D', - '--date-order', - '--max-count=1', - ]) - ).stdout.split('\x09'); - const latestCommitRevs = latestCommitRevsMarker.split(' -> '); + const latestCommitsInReverse = ( + await environment.runCommand('git', [ + 'log', + '--pretty=%s%x09%H%x09%D', + '--date-order', + '--max-count=2', + ]) + ).stdout + .split('\n') + .map((line) => { + const [subject, commitId, revsMarker] = line.split('\x09'); + const revs = revsMarker.split(' -> '); + return { subject, commitId, revs }; + }); const latestBranchCommitId = ( await environment.runCommand('git', [ 'rev-list', @@ -598,10 +603,19 @@ describe('create-release-branch (functional)', () => { '--max-count=1', ]) ).stdout; - expect(latestCommitSubject).toBe('Release 2.0.0'); - expect(latestCommitRevs).toContain('HEAD'); - expect(latestCommitRevs).toContain('release/2.0.0'); - expect(latestBranchCommitId).toStrictEqual(latestCommitId); + expect(latestCommitsInReverse[0].subject).toBe( + 'Update Release 2.0.0', + ); + expect(latestCommitsInReverse[1].subject).toBe( + 'Initialize Release 2.0.0', + ); + + expect(latestCommitsInReverse[0].revs).toContain('HEAD'); + expect(latestCommitsInReverse[0].revs).toContain('release/2.0.0'); + + expect(latestBranchCommitId).toStrictEqual( + latestCommitsInReverse[0].commitId, + ); }, ); }); diff --git a/src/initial-parameters.test.ts b/src/initial-parameters.test.ts index 7b76583..0fde384 100644 --- a/src/initial-parameters.test.ts +++ b/src/initial-parameters.test.ts @@ -35,6 +35,7 @@ describe('initial-parameters', () => { tempDirectory: '/path/to/temp', reset: true, backport: false, + defaultBranch: 'main', }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -54,6 +55,7 @@ describe('initial-parameters', () => { tempDirectoryPath: '/path/to/temp', reset: true, releaseType: 'ordinary', + defaultBranch: 'main', }); }); @@ -69,6 +71,7 @@ describe('initial-parameters', () => { tempDirectory: undefined, reset: true, backport: false, + defaultBranch: 'main', }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -98,6 +101,7 @@ describe('initial-parameters', () => { tempDirectory: 'tmp', reset: true, backport: false, + defaultBranch: 'main', }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -127,6 +131,7 @@ describe('initial-parameters', () => { tempDirectory: undefined, reset: true, backport: false, + defaultBranch: 'main', }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -156,6 +161,7 @@ describe('initial-parameters', () => { tempDirectory: '/path/to/temp', reset: true, backport: false, + defaultBranch: 'main', }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -183,6 +189,7 @@ describe('initial-parameters', () => { tempDirectory: '/path/to/temp', reset: false, backport: false, + defaultBranch: 'main', }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -210,6 +217,7 @@ describe('initial-parameters', () => { tempDirectory: '/path/to/temp', reset: false, backport: true, + defaultBranch: 'main', }); jest .spyOn(envModule, 'getEnvironmentVariables') @@ -237,6 +245,7 @@ describe('initial-parameters', () => { tempDirectory: '/path/to/temp', reset: false, backport: false, + defaultBranch: 'main', }); jest .spyOn(envModule, 'getEnvironmentVariables') diff --git a/src/initial-parameters.ts b/src/initial-parameters.ts index a6086a3..94a4723 100644 --- a/src/initial-parameters.ts +++ b/src/initial-parameters.ts @@ -20,6 +20,7 @@ type InitialParameters = { tempDirectoryPath: string; reset: boolean; releaseType: ReleaseType; + defaultBranch: string; }; /** @@ -58,6 +59,7 @@ export async function determineInitialParameters({ project, tempDirectoryPath, reset: args.reset, + defaultBranch: args.defaultBranch, releaseType: args.backport ? 'backport' : 'ordinary', }; } diff --git a/src/main.test.ts b/src/main.test.ts index 82dff54..fc82683 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -18,6 +18,7 @@ describe('main', () => { project, tempDirectoryPath: '/path/to/temp/directory', reset: true, + defaultBranch: 'main', releaseType: 'backport', }); const followMonorepoWorkflowSpy = jest @@ -36,6 +37,7 @@ describe('main', () => { tempDirectoryPath: '/path/to/temp/directory', firstRemovingExistingReleaseSpecification: true, releaseType: 'backport', + defaultBranch: 'main', stdout, stderr, }); @@ -51,6 +53,7 @@ describe('main', () => { project, tempDirectoryPath: '/path/to/temp/directory', reset: false, + defaultBranch: 'main', releaseType: 'backport', }); const followMonorepoWorkflowSpy = jest diff --git a/src/main.ts b/src/main.ts index 7c5e876..3eac13a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,7 +25,7 @@ export async function main({ stdout: Pick; stderr: Pick; }) { - const { project, tempDirectoryPath, reset, releaseType } = + const { project, tempDirectoryPath, reset, releaseType, defaultBranch } = await determineInitialParameters({ argv, cwd, stderr }); if (project.isMonorepo) { @@ -37,6 +37,7 @@ export async function main({ tempDirectoryPath, firstRemovingExistingReleaseSpecification: reset, releaseType, + defaultBranch, stdout, stderr, }); diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index e1c1e20..89d1264 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -7,12 +7,12 @@ import { buildMockProject, Require } from '../tests/unit/helpers'; import { followMonorepoWorkflow } from './monorepo-workflow-operations'; import * as editorModule from './editor'; import type { Editor } from './editor'; -import { ReleaseType } from './initial-parameters'; import * as releaseSpecificationModule from './release-specification'; import type { ReleaseSpecification } from './release-specification'; import * as releasePlanModule from './release-plan'; import type { ReleasePlan } from './release-plan'; import * as repoModule from './repo'; +import * as workflowOperations from './workflow-operations'; jest.mock('./editor'); jest.mock('./release-plan'); @@ -46,6 +46,10 @@ async function fileExists(entryPath: string): Promise { function getDependencySpies() { return { determineEditorSpy: jest.spyOn(editorModule, 'determineEditor'), + createReleaseBranchSpy: jest.spyOn( + workflowOperations, + 'createReleaseBranch', + ), generateReleaseSpecificationTemplateForMonorepoSpy: jest.spyOn( releaseSpecificationModule, 'generateReleaseSpecificationTemplateForMonorepo', @@ -60,10 +64,7 @@ function getDependencySpies() { ), planReleaseSpy: jest.spyOn(releasePlanModule, 'planRelease'), executeReleasePlanSpy: jest.spyOn(releasePlanModule, 'executeReleasePlan'), - captureChangesInReleaseBranchSpy: jest.spyOn( - repoModule, - 'captureChangesInReleaseBranch', - ), + commitAllChangesSpy: jest.spyOn(repoModule, 'commitAllChanges'), }; } @@ -148,7 +149,6 @@ function buildMockEditor({ * `executeReleasePlan` will throw. * @param args.releaseVersion - The new version that the release plan will * contain. - * @param args.releaseType - The type of release. * @returns Mock functions and other data that can be used in tests to make * assertions. */ @@ -161,7 +161,6 @@ async function setupFollowMonorepoWorkflow({ errorUponPlanningRelease, errorUponExecutingReleasePlan, releaseVersion = '1.0.0', - releaseType = 'ordinary', }: { sandbox: Sandbox; doesReleaseSpecFileExist: boolean; @@ -171,16 +170,16 @@ async function setupFollowMonorepoWorkflow({ errorUponPlanningRelease?: Error; errorUponExecutingReleasePlan?: Error; releaseVersion?: string; - releaseType?: ReleaseType; }) { const { determineEditorSpy, + createReleaseBranchSpy, generateReleaseSpecificationTemplateForMonorepoSpy, waitForUserToEditReleaseSpecificationSpy, validateReleaseSpecificationSpy, planReleaseSpy, executeReleasePlanSpy, - captureChangesInReleaseBranchSpy, + commitAllChangesSpy, } = getDependencySpies(); const editor = buildMockEditor(); const releaseSpecificationPath = path.join( @@ -222,11 +221,19 @@ async function setupFollowMonorepoWorkflow({ if (errorUponPlanningRelease) { when(planReleaseSpy) - .calledWith({ project, releaseSpecification, releaseType }) + .calledWith({ + project, + releaseSpecification, + newReleaseVersion: releaseVersion, + }) .mockRejectedValue(errorUponPlanningRelease); } else { when(planReleaseSpy) - .calledWith({ project, releaseSpecification, releaseType }) + .calledWith({ + project, + releaseSpecification, + newReleaseVersion: releaseVersion, + }) .mockResolvedValue(releasePlan); } @@ -240,10 +247,8 @@ async function setupFollowMonorepoWorkflow({ .mockResolvedValue(undefined); } - when(captureChangesInReleaseBranchSpy) - .calledWith(projectDirectoryPath, { - releaseVersion, - }) + when(commitAllChangesSpy) + .calledWith(projectDirectoryPath, '') .mockResolvedValue(); if (doesReleaseSpecFileExist) { @@ -263,7 +268,8 @@ async function setupFollowMonorepoWorkflow({ releaseSpecification, planReleaseSpy, executeReleasePlanSpy, - captureChangesInReleaseBranchSpy, + commitAllChangesSpy, + createReleaseBranchSpy, releasePlan, releaseVersion, releaseSpecificationPath, @@ -273,6 +279,58 @@ async function setupFollowMonorepoWorkflow({ describe('monorepo-workflow-operations', () => { describe('followMonorepoWorkflow', () => { describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, and an editor is available', () => { + it('should call createReleaseBranch with the correct arguments if given releaseType: "ordinary"', async () => { + await withSandbox(async (sandbox) => { + const { project, stdout, stderr, createReleaseBranchSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + releaseType: 'ordinary', + defaultBranch: 'main', + stdout, + stderr, + }); + + expect(createReleaseBranchSpy).toHaveBeenCalledWith({ + project, + releaseType: 'ordinary', + }); + }); + }); + + it('should call createReleaseBranch with the correct arguments if given releaseType: "backport"', async () => { + await withSandbox(async (sandbox) => { + const { project, stdout, stderr, createReleaseBranchSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + releaseType: 'backport', + defaultBranch: 'main', + stdout, + stderr, + }); + + expect(createReleaseBranchSpy).toHaveBeenCalledWith({ + project, + releaseType: 'backport', + }); + }); + }); + it('plans an ordinary release if given releaseType: "ordinary"', async () => { await withSandbox(async (sandbox) => { const { @@ -285,7 +343,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - releaseType: 'ordinary', }); await followMonorepoWorkflow({ @@ -293,6 +350,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -300,7 +358,7 @@ describe('monorepo-workflow-operations', () => { expect(planReleaseSpy).toHaveBeenCalledWith({ project, releaseSpecification, - releaseType: 'ordinary', + newReleaseVersion: '2.0.0', }); }); }); @@ -317,7 +375,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - releaseType: 'backport', }); await followMonorepoWorkflow({ @@ -325,6 +382,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'backport', + defaultBranch: 'main', stdout, stderr, }); @@ -332,7 +390,7 @@ describe('monorepo-workflow-operations', () => { expect(planReleaseSpy).toHaveBeenCalledWith({ project, releaseSpecification, - releaseType: 'backport', + newReleaseVersion: '1.1.0', }); }); }); @@ -349,6 +407,7 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, + releaseVersion: '2.0.0', }); await followMonorepoWorkflow({ @@ -356,6 +415,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -368,13 +428,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('creates a new branch named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { + it('should make exactly two commits named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { const { project, stdout, stderr, - captureChangesInReleaseBranchSpy, + commitAllChangesSpy, projectDirectoryPath, } = await setupFollowMonorepoWorkflow({ sandbox, @@ -388,13 +448,19 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); - expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + expect(commitAllChangesSpy).toHaveBeenCalledWith( + projectDirectoryPath, + 'Initialize Release 2.0.0', + ); + + expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - { releaseVersion: '4.38.0' }, + 'Update Release 2.0.0', ); }); }); @@ -413,6 +479,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -437,6 +504,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -446,15 +514,20 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not attempt to create a new branch if the release spec was not successfully edited', async () => { + it('does not attempt to make the final release update commit when release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, captureChangesInReleaseBranchSpy } = - await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), - }); + const { + project, + stdout, + stderr, + commitAllChangesSpy, + projectDirectoryPath, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); await expect( followMonorepoWorkflow({ @@ -462,12 +535,20 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), ).rejects.toThrow(expect.anything()); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + expect(commitAllChangesSpy).toHaveBeenCalledWith( + projectDirectoryPath, + 'Initialize Release 2.0.0', + ); + expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + projectDirectoryPath, + 'Update Release 2.0.0', + ); }); }); @@ -487,6 +568,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -513,6 +595,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -537,6 +620,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -555,6 +639,7 @@ describe('monorepo-workflow-operations', () => { doesReleaseSpecFileExist: false, isEditorAvailable: true, errorUponPlanningRelease, + releaseVersion: '2.0.0', }); await expect( @@ -563,6 +648,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -581,6 +667,7 @@ describe('monorepo-workflow-operations', () => { doesReleaseSpecFileExist: false, isEditorAvailable: true, errorUponExecutingReleasePlan, + releaseVersion: '2.0.0', }); await expect( @@ -589,6 +676,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -614,6 +702,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -622,25 +711,34 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not attempt to create a new branch', async () => { + it('does not attempt to make the release update commit', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, captureChangesInReleaseBranchSpy } = - await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: false, - }); + const { + project, + stdout, + stderr, + commitAllChangesSpy, + projectDirectoryPath, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + }); await followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + projectDirectoryPath, + 'Update Release 2.0.0', + ); }); }); @@ -659,6 +757,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -683,6 +782,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -710,6 +810,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -731,7 +832,6 @@ describe('monorepo-workflow-operations', () => { } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, - releaseType: 'ordinary', }); await followMonorepoWorkflow({ @@ -739,6 +839,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -746,7 +847,7 @@ describe('monorepo-workflow-operations', () => { expect(planReleaseSpy).toHaveBeenCalledWith({ project, releaseSpecification, - releaseType: 'ordinary', + newReleaseVersion: '2.0.0', }); }); }); @@ -762,7 +863,6 @@ describe('monorepo-workflow-operations', () => { } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, - releaseType: 'backport', }); await followMonorepoWorkflow({ @@ -770,6 +870,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'backport', + defaultBranch: 'main', stdout, stderr, }); @@ -777,7 +878,7 @@ describe('monorepo-workflow-operations', () => { expect(planReleaseSpy).toHaveBeenCalledWith({ project, releaseSpecification, - releaseType: 'backport', + newReleaseVersion: '1.1.0', }); }); }); @@ -793,6 +894,7 @@ describe('monorepo-workflow-operations', () => { } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, + releaseVersion: '2.0.0', }); await followMonorepoWorkflow({ @@ -800,6 +902,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -812,13 +915,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('creates a new branch named after the generated release version if validating and executing the release spec succeeds', async () => { + it('should make exactly two commits named after the generated release version if validating and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { const { project, stdout, stderr, - captureChangesInReleaseBranchSpy, + commitAllChangesSpy, projectDirectoryPath, } = await setupFollowMonorepoWorkflow({ sandbox, @@ -831,13 +934,19 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); - expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + expect(commitAllChangesSpy).toHaveBeenCalledWith( + projectDirectoryPath, + 'Initialize Release 2.0.0', + ); + + expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - { releaseVersion: '4.38.0' }, + 'Update Release 2.0.0', ); }); }); @@ -855,6 +964,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -879,6 +989,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -896,6 +1007,7 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: true, errorUponPlanningRelease, + releaseVersion: '2.0.0', }); await expect( @@ -904,6 +1016,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -921,6 +1034,7 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: true, errorUponExecutingReleasePlan, + releaseVersion: '2.0.0', }); await expect( @@ -929,6 +1043,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -952,7 +1067,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - releaseType: 'ordinary', }); await followMonorepoWorkflow({ @@ -960,6 +1074,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -967,7 +1082,7 @@ describe('monorepo-workflow-operations', () => { expect(planReleaseSpy).toHaveBeenCalledWith({ project, releaseSpecification, - releaseType: 'ordinary', + newReleaseVersion: '2.0.0', }); }); }); @@ -984,7 +1099,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - releaseType: 'backport', }); await followMonorepoWorkflow({ @@ -992,6 +1106,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'backport', + defaultBranch: 'main', stdout, stderr, }); @@ -999,7 +1114,7 @@ describe('monorepo-workflow-operations', () => { expect(planReleaseSpy).toHaveBeenCalledWith({ project, releaseSpecification, - releaseType: 'backport', + newReleaseVersion: '1.1.0', }); }); }); @@ -1016,6 +1131,7 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, + releaseVersion: '2.0.0', }); await followMonorepoWorkflow({ @@ -1023,6 +1139,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1035,13 +1152,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('creates a new branch named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { + it('should make exactly two commits named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { const { project, stdout, stderr, - captureChangesInReleaseBranchSpy, + commitAllChangesSpy, projectDirectoryPath, } = await setupFollowMonorepoWorkflow({ sandbox, @@ -1055,13 +1172,19 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); - expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - { releaseVersion: '4.38.0' }, + 'Initialize Release 2.0.0', + ); + + expect(commitAllChangesSpy).toHaveBeenCalledWith( + projectDirectoryPath, + 'Update Release 2.0.0', ); }); }); @@ -1080,6 +1203,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1104,6 +1228,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1113,15 +1238,20 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not attempt to create a new branch if the release spec was not successfully edited', async () => { + it('does not attempt to make the release update commit if the release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, captureChangesInReleaseBranchSpy } = - await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), - }); + const { + project, + stdout, + stderr, + commitAllChangesSpy, + projectDirectoryPath, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); await expect( followMonorepoWorkflow({ @@ -1129,12 +1259,16 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), ).rejects.toThrow(expect.anything()); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + projectDirectoryPath, + 'Update Release 2.0.0', + ); }); }); @@ -1154,6 +1288,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1180,6 +1315,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1204,6 +1340,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1222,6 +1359,7 @@ describe('monorepo-workflow-operations', () => { doesReleaseSpecFileExist: false, isEditorAvailable: true, errorUponPlanningRelease, + releaseVersion: '2.0.0', }); await expect( @@ -1230,6 +1368,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1248,6 +1387,7 @@ describe('monorepo-workflow-operations', () => { doesReleaseSpecFileExist: false, isEditorAvailable: true, errorUponExecutingReleasePlan, + releaseVersion: '2.0.0', }); await expect( @@ -1256,6 +1396,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1281,6 +1422,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1289,25 +1431,34 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not attempt to create a new branch', async () => { + it('does not attempt to make the release update commit', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, captureChangesInReleaseBranchSpy } = - await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: false, - }); + const { + project, + stdout, + stderr, + commitAllChangesSpy, + projectDirectoryPath, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + }); await followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + projectDirectoryPath, + 'Update Release 2.0.0', + ); }); }); @@ -1326,6 +1477,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1350,6 +1502,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1378,6 +1531,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1403,7 +1557,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, - releaseType: 'ordinary', }); await followMonorepoWorkflow({ @@ -1411,6 +1564,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1418,7 +1572,7 @@ describe('monorepo-workflow-operations', () => { expect(planReleaseSpy).toHaveBeenCalledWith({ project, releaseSpecification, - releaseType: 'ordinary', + newReleaseVersion: '2.0.0', }); }); }); @@ -1435,7 +1589,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, - releaseType: 'backport', }); await followMonorepoWorkflow({ @@ -1443,6 +1596,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'backport', + defaultBranch: 'main', stdout, stderr, }); @@ -1450,7 +1604,7 @@ describe('monorepo-workflow-operations', () => { expect(planReleaseSpy).toHaveBeenCalledWith({ project, releaseSpecification, - releaseType: 'backport', + newReleaseVersion: '1.1.0', }); }); }); @@ -1467,6 +1621,7 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, + releaseVersion: '2.0.0', }); await followMonorepoWorkflow({ @@ -1474,6 +1629,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1486,13 +1642,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('creates a new branch named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { + it('should make exactly two commits named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { const { project, stdout, stderr, - captureChangesInReleaseBranchSpy, + commitAllChangesSpy, projectDirectoryPath, } = await setupFollowMonorepoWorkflow({ sandbox, @@ -1506,13 +1662,19 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); - expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + expect(commitAllChangesSpy).toHaveBeenCalledWith( + projectDirectoryPath, + 'Initialize Release 2.0.0', + ); + + expect(commitAllChangesSpy).toHaveBeenCalledWith( projectDirectoryPath, - { releaseVersion: '4.38.0' }, + 'Update Release 2.0.0', ); }); }); @@ -1531,6 +1693,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1555,6 +1718,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1564,15 +1728,20 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not attempt to create a new branch if the release spec was not successfully edited', async () => { + it('does not attempt to make the release update commit if the release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, captureChangesInReleaseBranchSpy } = - await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), - }); + const { + project, + stdout, + stderr, + commitAllChangesSpy, + projectDirectoryPath, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); await expect( followMonorepoWorkflow({ @@ -1580,12 +1749,16 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), ).rejects.toThrow(expect.anything()); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + projectDirectoryPath, + 'Update Release 2.0.0', + ); }); }); @@ -1605,6 +1778,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1631,6 +1805,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1655,6 +1830,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1673,6 +1849,7 @@ describe('monorepo-workflow-operations', () => { doesReleaseSpecFileExist: true, isEditorAvailable: true, errorUponPlanningRelease, + releaseVersion: '2.0.0', }); await expect( @@ -1681,6 +1858,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1699,6 +1877,7 @@ describe('monorepo-workflow-operations', () => { doesReleaseSpecFileExist: true, isEditorAvailable: true, errorUponExecutingReleasePlan, + releaseVersion: '2.0.0', }); await expect( @@ -1707,6 +1886,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }), @@ -1736,6 +1916,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1763,6 +1944,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1771,25 +1953,34 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not attempt to create a new branch', async () => { + it('does not attempt to make the release update commit', async () => { await withSandbox(async (sandbox) => { - const { project, stdout, stderr, captureChangesInReleaseBranchSpy } = - await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: false, - }); + const { + project, + stdout, + stderr, + commitAllChangesSpy, + projectDirectoryPath, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: false, + }); await followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + expect(commitAllChangesSpy).not.toHaveBeenCalledWith( + projectDirectoryPath, + 'Update Release 2.0.0', + ); }); }); @@ -1808,6 +1999,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); @@ -1832,6 +2024,7 @@ describe('monorepo-workflow-operations', () => { tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, releaseType: 'ordinary', + defaultBranch: 'main', stdout, stderr, }); diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 5409ba5..c5cf49e 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -8,14 +8,19 @@ import { } from './fs'; import { determineEditor } from './editor'; import { ReleaseType } from './initial-parameters'; -import { Project } from './project'; +import { + Project, + updateChangelogsForChangedPackages, + restoreChangelogsForSkippedPackages, +} from './project'; import { planRelease, executeReleasePlan } from './release-plan'; -import { captureChangesInReleaseBranch } from './repo'; +import { commitAllChanges } from './repo'; import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, validateReleaseSpecification, } from './release-specification'; +import { createReleaseBranch } from './workflow-operations'; /** * For a monorepo, the process works like this: @@ -46,6 +51,7 @@ import { * first. * @param args.releaseType - The type of release ("ordinary" or "backport"), * which affects how the version is bumped. + * @param args.defaultBranch - The name of the default branch in the repository. * @param args.stdout - A stream that can be used to write to standard out. * @param args.stderr - A stream that can be used to write to standard error. */ @@ -54,6 +60,7 @@ export async function followMonorepoWorkflow({ tempDirectoryPath, firstRemovingExistingReleaseSpecification, releaseType, + defaultBranch, stdout, stderr, }: { @@ -61,9 +68,23 @@ export async function followMonorepoWorkflow({ tempDirectoryPath: string; firstRemovingExistingReleaseSpecification: boolean; releaseType: ReleaseType; + defaultBranch: string; stdout: Pick; stderr: Pick; }) { + const { version: newReleaseVersion, firstRun } = await createReleaseBranch({ + project, + releaseType, + }); + + if (firstRun) { + await updateChangelogsForChangedPackages({ project, stderr }); + await commitAllChanges( + project.directoryPath, + `Initialize Release ${newReleaseVersion}`, + ); + } + const releaseSpecificationPath = path.join( tempDirectoryPath, 'RELEASE_SPEC.yml', @@ -112,14 +133,22 @@ export async function followMonorepoWorkflow({ project, releaseSpecificationPath, ); + + await restoreChangelogsForSkippedPackages({ + project, + releaseSpecification, + defaultBranch, + }); + const releasePlan = await planRelease({ project, releaseSpecification, - releaseType, + newReleaseVersion, }); await executeReleasePlan(project, releasePlan, stderr); await removeFile(releaseSpecificationPath); - await captureChangesInReleaseBranch(project.directoryPath, { - releaseVersion: releasePlan.newVersion, - }); + await commitAllChanges( + project.directoryPath, + `Update Release ${newReleaseVersion}`, + ); } diff --git a/src/package.test.ts b/src/package.test.ts index 78ee319..ad10dec 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -4,6 +4,7 @@ import { when } from 'jest-when'; import * as autoChangelog from '@metamask/auto-changelog'; import { SemVer } from 'semver'; import { MockWritable } from 'stdio-mock'; +import _outdent from 'outdent'; import { withSandbox } from '../tests/helpers'; import { buildMockPackage, @@ -15,12 +16,14 @@ import { readMonorepoRootPackage, readMonorepoWorkspacePackage, updatePackage, + updatePackageChangelog, } from './package'; import * as fsModule from './fs'; import * as packageManifestModule from './package-manifest'; import * as repoModule from './repo'; -jest.mock('@metamask/auto-changelog'); +const outdent = _outdent({ trimTrailingNewline: false }); + jest.mock('./package-manifest'); jest.mock('./repo'); @@ -437,7 +440,6 @@ describe('package', () => { changelogPath: path.join(sandbox.directoryPath, 'CHANGELOG.md'), }), newVersion: '2.0.0', - shouldUpdateChangelog: false, }; await updatePackage({ project, packageReleasePlan }); @@ -451,7 +453,7 @@ describe('package', () => { }); }); - it('updates the changelog of the package if requested to do so and if the package has one', async () => { + it('migrates all unreleased changes to a release section', async () => { await withSandbox(async (sandbox) => { const project = buildMockProject({ repositoryUrl: 'https://repo.url', @@ -465,19 +467,25 @@ describe('package', () => { changelogPath, }), newVersion: '2.0.0', - shouldUpdateChangelog: true, }; - when(jest.spyOn(autoChangelog, 'updateChangelog')) - .calledWith({ - changelogContent: 'existing changelog', - currentVersion: '2.0.0', - isReleaseCandidate: true, - projectRootDirectory: sandbox.directoryPath, - repoUrl: 'https://repo.url', - tagPrefixes: ['package@', 'v'], - }) - .mockResolvedValue('new changelog'); - await fs.promises.writeFile(changelogPath, 'existing changelog'); + + await fs.promises.writeFile( + changelogPath, + outdent` + # Changelog + All notable changes to this project will be documented in this file. + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), + and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ## [Unreleased] + ### Uncategorized + - Add \`isNewFunction\` ([#2](https://repo.url/compare/package/pull/2)) + ## [1.0.0] - 2020-01-01 + ### Changed + - Something else + [Unreleased]: https://repo.url/compare/package@2.0.0...HEAD + [1.0.0]: https://repo.url/releases/tag/package@1.0.0 + `, + ); await updatePackage({ project, packageReleasePlan }); @@ -485,7 +493,28 @@ describe('package', () => { changelogPath, 'utf8', ); - expect(newChangelogContent).toBe('new changelog'); + + expect(newChangelogContent).toBe(outdent` + # Changelog + All notable changes to this project will be documented in this file. + + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), + and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + ## [Unreleased] + + ## [2.0.0] + ### Uncategorized + - Add \`isNewFunction\` ([#2](https://repo.url/compare/package/pull/2)) + + ## [1.0.0] - 2020-01-01 + ### Changed + - Something else + + [Unreleased]: https://repo.url/compare/package@2.0.0...HEAD + [2.0.0]: https://repo.url/compare/package@1.0.0...package@2.0.0 + [1.0.0]: https://repo.url/releases/tag/package@1.0.0 + `); }); }); @@ -501,7 +530,6 @@ describe('package', () => { changelogPath, }), newVersion: '2.0.0', - shouldUpdateChangelog: true, }; jest.spyOn(fsModule, 'readFile').mockRejectedValue(new Error('oops')); @@ -523,83 +551,83 @@ describe('package', () => { changelogPath, }), newVersion: '2.0.0', - shouldUpdateChangelog: true, }; - jest - .spyOn(autoChangelog, 'updateChangelog') - .mockResolvedValue('new changelog'); const result = await updatePackage({ project, packageReleasePlan }); expect(result).toBeUndefined(); }); }); + }); - it('does not update the changelog if updateChangelog returns nothing', async () => { + describe('updatePackageChangelog', () => { + it('updates the changelog of the package if requested to do so and if the package has one', async () => { await withSandbox(async (sandbox) => { + const stderr = createNoopWriteStream(); const project = buildMockProject({ repositoryUrl: 'https://repo.url', }); const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); - const packageReleasePlan = { - package: buildMockPackage({ - directoryPath: sandbox.directoryPath, - manifestPath: path.join(sandbox.directoryPath, 'package.json'), - validatedManifest: buildMockManifest(), - changelogPath, - }), - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }; + const pkg = buildMockPackage({ + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + validatedManifest: buildMockManifest(), + changelogPath, + }); when(jest.spyOn(autoChangelog, 'updateChangelog')) .calledWith({ changelogContent: 'existing changelog', - currentVersion: '2.0.0', - isReleaseCandidate: true, + isReleaseCandidate: false, projectRootDirectory: sandbox.directoryPath, repoUrl: 'https://repo.url', + tagPrefixes: ['package@', 'v'], }) - .mockResolvedValue(undefined); + .mockResolvedValue('new changelog'); await fs.promises.writeFile(changelogPath, 'existing changelog'); - await updatePackage({ project, packageReleasePlan }); + await updatePackageChangelog({ + project, + package: pkg, + stderr, + }); const newChangelogContent = await fs.promises.readFile( changelogPath, 'utf8', ); - expect(newChangelogContent).toBe('existing changelog'); + expect(newChangelogContent).toBe('new changelog'); }); }); - it('does not update the changelog if not requested to do so', async () => { + it('does not update the changelog if updateChangelog returns nothing', async () => { await withSandbox(async (sandbox) => { + const stderr = createNoopWriteStream(); const project = buildMockProject({ repositoryUrl: 'https://repo.url', }); const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); - const packageReleasePlan = { - package: buildMockPackage({ - directoryPath: sandbox.directoryPath, - manifestPath: path.join(sandbox.directoryPath, 'package.json'), - validatedManifest: buildMockManifest(), - changelogPath, - }), - newVersion: '2.0.0', - shouldUpdateChangelog: false, - }; + const pkg = buildMockPackage({ + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + validatedManifest: buildMockManifest(), + changelogPath, + }); when(jest.spyOn(autoChangelog, 'updateChangelog')) .calledWith({ changelogContent: 'existing changelog', - currentVersion: '2.0.0', - isReleaseCandidate: true, + isReleaseCandidate: false, projectRootDirectory: sandbox.directoryPath, repoUrl: 'https://repo.url', + tagPrefixes: ['package@', 'v'], }) - .mockResolvedValue('new changelog'); + .mockResolvedValue(undefined); await fs.promises.writeFile(changelogPath, 'existing changelog'); - await updatePackage({ project, packageReleasePlan }); + await updatePackageChangelog({ + project, + package: pkg, + stderr, + }); const newChangelogContent = await fs.promises.readFile( changelogPath, @@ -608,5 +636,50 @@ describe('package', () => { expect(newChangelogContent).toBe('existing changelog'); }); }); + + it('does not throw but merely prints a warning if the package does not have a changelog', async () => { + await withSandbox(async (sandbox) => { + const stderr = createNoopWriteStream(); + const project = buildMockProject({ + repositoryUrl: 'https://repo.url', + }); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const pkg = buildMockPackage({ + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + validatedManifest: buildMockManifest(), + changelogPath, + }); + + const result = await updatePackageChangelog({ + project, + package: pkg, + stderr, + }); + + expect(result).toBeUndefined(); + }); + }); + + it("throws if reading the package's changelog fails in an unexpected way", async () => { + await withSandbox(async (sandbox) => { + const stderr = createNoopWriteStream(); + const project = buildMockProject({ + repositoryUrl: 'https://repo.url', + }); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const pkg = buildMockPackage({ + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + validatedManifest: buildMockManifest(), + changelogPath, + }); + jest.spyOn(fsModule, 'readFile').mockRejectedValue(new Error('oops')); + + await expect( + updatePackageChangelog({ project, package: pkg, stderr }), + ).rejects.toThrow('oops'); + }); + }); }); }); diff --git a/src/package.ts b/src/package.ts index 3e76566..dc4d599 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,7 +1,7 @@ import fs, { WriteStream } from 'fs'; import path from 'path'; import { format } from 'util'; -import { updateChangelog } from '@metamask/auto-changelog'; +import { parseChangelog, updateChangelog } from '@metamask/auto-changelog'; import { WriteStreamLike, readFile, writeFile, writeJsonFile } from './fs'; import { isErrorWithCode } from './misc-utils'; import { @@ -229,6 +229,56 @@ export async function readMonorepoWorkspacePackage({ }; } +/** + * Migrate all unreleased changes to a release section. + * + * Changes are migrated in their existing categories, and placed above any + * pre-existing changes in that category. + * + * @param args - The arguments. + * @param args.project - The project. + * @param args.package - A particular package in the project. + * @param args.version - The release version to migrate unreleased changes to. + * @param args.stderr - A stream that can be used to write to standard error. + * @returns The result of writing to the changelog. + */ +export async function migrateUnreleasedChangelogChangesToRelease({ + project: { repositoryUrl }, + package: pkg, + version, + stderr, +}: { + project: Pick; + package: Package; + version: string; + stderr: Pick; +}): Promise { + let changelogContent; + + try { + changelogContent = await readFile(pkg.changelogPath); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + stderr.write( + `${pkg.validatedManifest.name} does not seem to have a changelog. Skipping.\n`, + ); + return; + } + + throw error; + } + + const changelog = parseChangelog({ + changelogContent, + repoUrl: repositoryUrl, + tagPrefix: `${pkg.validatedManifest.name}@`, + }); + + changelog.addRelease({ version }); + changelog.migrateUnreleasedChangesToRelease(version); + await writeFile(pkg.changelogPath, changelog.toString()); +} + /** * Updates the changelog file of the given package using * `@metamask/auto-changelog`. Assumes that the changelog file is located at the @@ -236,18 +286,17 @@ export async function readMonorepoWorkspacePackage({ * * @param args - The arguments. * @param args.project - The project. - * @param args.packageReleasePlan - The release plan for a particular package in - * the project. + * @param args.package - A particular package in the project. * @param args.stderr - A stream that can be used to write to standard error. * @returns The result of writing to the changelog. */ -async function updatePackageChangelog({ +export async function updatePackageChangelog({ project: { repositoryUrl }, - packageReleasePlan: { package: pkg, newVersion }, + package: pkg, stderr, }: { project: Pick; - packageReleasePlan: PackageReleasePlan; + package: Package; stderr: Pick; }): Promise { let changelogContent; @@ -267,8 +316,10 @@ async function updatePackageChangelog({ const newChangelogContent = await updateChangelog({ changelogContent, - currentVersion: newVersion, - isReleaseCandidate: true, + // Setting `isReleaseCandidate` to false because `updateChangelog` requires a + // specific version number when this flag is true, and the package release version + // is not determined at this stage of the process. + isReleaseCandidate: false, projectRootDirectory: pkg.directoryPath, repoUrl: repositoryUrl, tagPrefixes: [`${pkg.validatedManifest.name}@`, 'v'], @@ -284,9 +335,10 @@ async function updatePackageChangelog({ } /** - * Updates the package as per the instructions in the given release plan by - * replacing the `version` field in the manifest and adding a new section to the - * changelog for the new version of the package. + * Updates the package by replacing the `version` field in the manifest + * according to the one in the given release plan. Also updates the + * changelog by migrating changes in the Unreleased section to the section + * representing the new version. * * @param args - The project. * @param args.project - The project. @@ -304,18 +356,17 @@ export async function updatePackage({ packageReleasePlan: PackageReleasePlan; stderr?: Pick; }): Promise { - const { - package: pkg, - newVersion, - shouldUpdateChangelog, - } = packageReleasePlan; + const { package: pkg, newVersion } = packageReleasePlan; await writeJsonFile(pkg.manifestPath, { ...pkg.unvalidatedManifest, version: newVersion, }); - if (shouldUpdateChangelog) { - await updatePackageChangelog({ project, packageReleasePlan, stderr }); - } + await migrateUnreleasedChangelogChangesToRelease({ + project, + package: pkg, + stderr, + version: newVersion, + }); } diff --git a/src/project.test.ts b/src/project.test.ts index 20bb02f..faca939 100644 --- a/src/project.test.ts +++ b/src/project.test.ts @@ -1,13 +1,23 @@ -import fs from 'fs'; +import { mkdir } from 'fs/promises'; import path from 'path'; import { when } from 'jest-when'; import { SemVer } from 'semver'; import * as actionUtils from '@metamask/action-utils'; import { withSandbox } from '../tests/helpers'; -import { buildMockPackage, createNoopWriteStream } from '../tests/unit/helpers'; -import { readProject } from './project'; +import { + buildMockPackage, + buildMockProject, + createNoopWriteStream, +} from '../tests/unit/helpers'; +import { + readProject, + restoreChangelogsForSkippedPackages, + updateChangelogsForChangedPackages, +} from './project'; import * as packageModule from './package'; import * as repoModule from './repo'; +import * as fs from './fs'; +import { IncrementableVersionParts } from './release-specification'; jest.mock('./package'); jest.mock('./repo'); @@ -93,14 +103,10 @@ describe('project', () => { stderr, }) .mockResolvedValue(workspacePackages.b); - await fs.promises.mkdir(path.join(projectDirectoryPath, 'packages')); - await fs.promises.mkdir( - path.join(projectDirectoryPath, 'packages', 'a'), - ); - await fs.promises.mkdir( - path.join(projectDirectoryPath, 'packages', 'subpackages'), - ); - await fs.promises.mkdir( + await mkdir(path.join(projectDirectoryPath, 'packages')); + await mkdir(path.join(projectDirectoryPath, 'packages', 'a')); + await mkdir(path.join(projectDirectoryPath, 'packages', 'subpackages')); + await mkdir( path.join(projectDirectoryPath, 'packages', 'subpackages', 'b'), ); @@ -120,4 +126,189 @@ describe('project', () => { }); }); }); + describe('restoreChangelogsForSkippedPackages', () => { + it('should reset changelog for packages with changes not included in release', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + }), + c: buildMockPackage('c', { + hasChangesSinceLatestRelease: true, + }), + }, + }); + + const restoreFilesSpy = jest.spyOn(repoModule, 'restoreFiles'); + + when(jest.spyOn(fs, 'fileExists')) + .calledWith(project.workspacePackages.b.changelogPath) + .mockResolvedValue(true); + + when(jest.spyOn(fs, 'fileExists')) + .calledWith(project.workspacePackages.c.changelogPath) + .mockResolvedValue(true); + + await restoreChangelogsForSkippedPackages({ + project, + defaultBranch: 'main', + releaseSpecification: { + packages: { + a: IncrementableVersionParts.minor, + }, + path: '/path/to/release/specs', + }, + }); + + expect(restoreFilesSpy).toHaveBeenCalledWith( + project.directoryPath, + 'main', + [ + '/path/to/packages/b/CHANGELOG.md', + '/path/to/packages/c/CHANGELOG.md', + ], + ); + }); + + it('should not reset changelog for packages without changes since last release', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: false, + }), + }, + }); + + const restoreFilesSpy = jest.spyOn(repoModule, 'restoreFiles'); + + await restoreChangelogsForSkippedPackages({ + project, + defaultBranch: 'main', + releaseSpecification: { + packages: { + a: IncrementableVersionParts.minor, + }, + path: '/path/to/release/specs', + }, + }); + + expect(restoreFilesSpy).not.toHaveBeenCalledWith( + project.directoryPath, + 'main', + ['/path/to/packages/b/CHANGELOG.md'], + ); + }); + + it('should not reset non-existent changelogs', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + }), + }, + }); + + when(jest.spyOn(fs, 'fileExists')) + .calledWith(project.workspacePackages.a.changelogPath) + .mockResolvedValue(false); + + const restoreFilesSpy = jest.spyOn(repoModule, 'restoreFiles'); + + await restoreChangelogsForSkippedPackages({ + project, + defaultBranch: 'main', + releaseSpecification: { + packages: { + a: IncrementableVersionParts.minor, + }, + path: '/path/to/release/specs', + }, + }); + + expect(restoreFilesSpy).not.toHaveBeenCalledWith( + project.directoryPath, + 'main', + [project.workspacePackages.b.changelogPath], + ); + }); + }); + + describe('updateChangelogsForChangedPackages', () => { + it('should update changelog files of all the packages that has changes since latest release', async () => { + const stderr = createNoopWriteStream(); + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: true, + }), + b: buildMockPackage('b', { + hasChangesSinceLatestRelease: true, + }), + }, + }); + + const updatePackageChangelogSpy = jest.spyOn( + packageModule, + 'updatePackageChangelog', + ); + + await updateChangelogsForChangedPackages({ + project, + stderr, + }); + + expect(updatePackageChangelogSpy).toHaveBeenCalledWith({ + project, + package: project.workspacePackages.a, + stderr, + }); + + expect(updatePackageChangelogSpy).toHaveBeenCalledWith({ + project, + package: project.workspacePackages.b, + stderr, + }); + }); + + it('should not update changelog files of all the packages that has not changed since latest release', async () => { + const stderr = createNoopWriteStream(); + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a', { + hasChangesSinceLatestRelease: false, + }), + }, + }); + + const updatePackageChangelogSpy = jest.spyOn( + packageModule, + 'updatePackageChangelog', + ); + + await updateChangelogsForChangedPackages({ + project, + stderr, + }); + + expect(updatePackageChangelogSpy).not.toHaveBeenCalledWith({ + project, + package: project.workspacePackages.a, + stderr, + }); + }); + }); }); diff --git a/src/project.ts b/src/project.ts index 931f3ea..28b3e6e 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,14 +1,17 @@ +import { WriteStream } from 'fs'; import { resolve } from 'path'; import { getWorkspaceLocations } from '@metamask/action-utils'; -import { WriteStreamLike } from './fs'; +import { WriteStreamLike, fileExists } from './fs'; import { Package, readMonorepoRootPackage, readMonorepoWorkspacePackage, + updatePackageChangelog, } from './package'; -import { getRepositoryHttpsUrl, getTagNames } from './repo'; +import { getRepositoryHttpsUrl, getTagNames, restoreFiles } from './repo'; import { SemVer } from './semver'; import { PackageManifestFieldNames } from './package-manifest'; +import { ReleaseSpecification } from './release-specification'; /** * The release version of the root package of a monorepo extracted from its @@ -125,3 +128,85 @@ export async function readProject( releaseVersion, }; } + +/** + * Updates the changelog files of all packages that have changes since latest release to include those changes. + * + * @param args - The arguments. + * @param args.project - The project. + * @param args.stderr - A stream that can be used to write to standard error. + * @returns The result of writing to the changelog. + */ +export async function updateChangelogsForChangedPackages({ + project, + stderr, +}: { + project: Pick< + Project, + 'directoryPath' | 'repositoryUrl' | 'workspacePackages' + >; + stderr: Pick; +}): Promise { + await Promise.all( + Object.values(project.workspacePackages) + .filter( + ({ hasChangesSinceLatestRelease }) => hasChangesSinceLatestRelease, + ) + .map((pkg) => + updatePackageChangelog({ + project, + package: pkg, + stderr, + }), + ), + ); +} + +/** + * Restores the changelogs of unreleased packages which has changes since latest release. + * + * @param args - The arguments. + * @param args.project - The project. + * @param args.releaseSpecification - A parsed version of the release spec + * entered by the user. + * @param args.defaultBranch - The name of the default branch in the repository. + * @returns The result of writing to the changelog. + */ +export async function restoreChangelogsForSkippedPackages({ + project: { directoryPath, workspacePackages }, + releaseSpecification, + defaultBranch, +}: { + project: Pick< + Project, + 'directoryPath' | 'repositoryUrl' | 'workspacePackages' + >; + releaseSpecification: ReleaseSpecification; + defaultBranch: string; +}): Promise { + const existingSkippedPackageChangelogPaths = ( + await Promise.all( + Object.entries(workspacePackages).map(async ([name, pkg]) => { + const changelogPath = pkg.changelogPath.replace( + `${directoryPath}/`, + '', + ); + const shouldInclude = + pkg.hasChangesSinceLatestRelease && + !releaseSpecification.packages[name] && + (await fileExists(pkg.changelogPath)); + return [changelogPath, shouldInclude] as const; + }), + ) + ) + .filter(([_, shouldInclude]) => shouldInclude) + .map(([changelogPath]) => changelogPath); + + if (existingSkippedPackageChangelogPaths.length > 0) { + await restoreFiles( + directoryPath, + defaultBranch, + existingSkippedPackageChangelogPaths, + ); + } +} diff --git a/src/release-plan.test.ts b/src/release-plan.test.ts index d49e9ea..5975a6a 100644 --- a/src/release-plan.test.ts +++ b/src/release-plan.test.ts @@ -28,15 +28,15 @@ describe('release-plan-utils', () => { }, path: '/path/to/release/spec', }; - + const newReleaseVersion = '2.0.0'; const releasePlan = await planRelease({ project, releaseSpecification, - releaseType: 'ordinary', + newReleaseVersion, }); expect(releasePlan).toMatchObject({ - newVersion: '2.0.0', + newVersion: newReleaseVersion, packages: [ { package: project.rootPackage, @@ -81,15 +81,15 @@ describe('release-plan-utils', () => { }, path: '/path/to/release/spec', }; - + const newReleaseVersion = '1.1.0'; const releasePlan = await planRelease({ project, releaseSpecification, - releaseType: 'backport', + newReleaseVersion, }); expect(releasePlan).toMatchObject({ - newVersion: '1.1.0', + newVersion: newReleaseVersion, packages: [ { package: project.rootPackage, @@ -134,34 +134,28 @@ describe('release-plan-utils', () => { }, path: '/path/to/release/spec', }; - const releasePlan = await planRelease({ project, releaseSpecification, - releaseType: 'ordinary', + newReleaseVersion: '2.0.0', }); expect(releasePlan).toMatchObject({ packages: [ { package: project.rootPackage, - shouldUpdateChangelog: false, }, { package: project.workspacePackages.a, - shouldUpdateChangelog: true, }, { package: project.workspacePackages.b, - shouldUpdateChangelog: true, }, { package: project.workspacePackages.c, - shouldUpdateChangelog: true, }, { package: project.workspacePackages.d, - shouldUpdateChangelog: true, }, ], }); @@ -177,12 +171,10 @@ describe('release-plan-utils', () => { { package: buildMockPackage(), newVersion: '1.2.3', - shouldUpdateChangelog: true, }, { package: buildMockPackage(), newVersion: '1.2.3', - shouldUpdateChangelog: true, }, ], }; diff --git a/src/release-plan.ts b/src/release-plan.ts index 0ee5223..85b40f0 100644 --- a/src/release-plan.ts +++ b/src/release-plan.ts @@ -1,6 +1,5 @@ -import type { WriteStream } from 'fs'; +import { WriteStream } from 'fs'; import { SemVer } from 'semver'; -import { ReleaseType } from './initial-parameters'; import { debug } from './misc-utils'; import { Package, updatePackage } from './package'; import { Project } from './project'; @@ -35,50 +34,36 @@ export type ReleasePlan = { * @property package - Information about the package. * @property newVersion - The new version for the package, as a * SemVer-compatible string. - * @property shouldUpdateChangelog - Whether or not the changelog for the - * package should get updated. For a polyrepo, this will always be true; for a - * monorepo, this will be true only for workspace packages (the root package - * doesn't have a changelog, since it is a virtual package). */ export type PackageReleasePlan = { package: Package; newVersion: string; - shouldUpdateChangelog: boolean; }; /** * Uses the release specification to calculate the final versions of all of the - * packages that we want to update, as well as a new release name. + * packages that we want to update. * * @param args - The arguments. * @param args.project - Information about the whole project (e.g., names of * packages and where they can found). * @param args.releaseSpecification - A parsed version of the release spec * entered by the user. - * @param args.releaseType - The type of release ("ordinary" or "backport"), - * which affects how the version is bumped. + * @param args.newReleaseVersion - The new release version. * @returns A promise for information about the new release. */ export async function planRelease({ project, releaseSpecification, - releaseType, + newReleaseVersion, }: { project: Project; releaseSpecification: ReleaseSpecification; - releaseType: ReleaseType; + newReleaseVersion: string; }): Promise { - const newReleaseVersion = - releaseType === 'backport' - ? `${project.releaseVersion.ordinaryNumber}.${ - project.releaseVersion.backportNumber + 1 - }.0` - : `${project.releaseVersion.ordinaryNumber + 1}.0.0`; - const rootReleasePlan: PackageReleasePlan = { package: project.rootPackage, newVersion: newReleaseVersion, - shouldUpdateChangelog: false, }; const workspaceReleasePlans: PackageReleasePlan[] = Object.keys( @@ -95,7 +80,6 @@ export async function planRelease({ return { package: pkg, newVersion: newVersion.toString(), - shouldUpdateChangelog: true, }; }); @@ -106,8 +90,7 @@ export async function planRelease({ } /** - * Bumps versions and updates changelogs of packages within the monorepo - * according to the release plan. + * Bumps versions of packages within the monorepo according to the release plan. * * @param project - Information about the whole project (e.g., names of packages * and where they can found). diff --git a/src/repo.test.ts b/src/repo.test.ts index 667c35e..3bc1ff1 100644 --- a/src/repo.test.ts +++ b/src/repo.test.ts @@ -1,9 +1,12 @@ import { when } from 'jest-when'; import { getRepositoryHttpsUrl, - captureChangesInReleaseBranch, + commitAllChanges, getTagNames, hasChangesInDirectorySinceGitTag, + getCurrentBranchName, + branchExists, + restoreFiles, } from './repo'; import * as miscUtils from './misc-utils'; @@ -75,22 +78,15 @@ describe('repo', () => { }); }); - describe('captureChangesInReleaseBranch', () => { - it('checks out a new branch, stages all files, and creates a new commit', async () => { + describe('commitAllChanges', () => { + it('stages all files, and creates a new commit', async () => { const getStdoutFromCommandSpy = jest.spyOn( miscUtils, 'getStdoutFromCommand', ); - - await captureChangesInReleaseBranch('/path/to/project', { - releaseVersion: '1.0.0', - }); + const commitMessage = 'Release 1.0.0'; + await commitAllChanges('/path/to/project', commitMessage); - expect(getStdoutFromCommandSpy).toHaveBeenCalledWith( - 'git', - ['checkout', '-b', 'release/1.0.0'], - { cwd: '/path/to/project' }, - ); expect(getStdoutFromCommandSpy).toHaveBeenCalledWith( 'git', ['add', '-A'], @@ -98,7 +94,7 @@ describe('repo', () => { ); expect(getStdoutFromCommandSpy).toHaveBeenCalledWith( 'git', - ['commit', '-m', 'Release 1.0.0'], + ['commit', '-m', commitMessage], { cwd: '/path/to/project' }, ); }); @@ -221,4 +217,86 @@ describe('repo', () => { expect(getLinesFromCommandSpy).toHaveBeenCalledTimes(1); }); }); + + describe('getCurrentBranchName', () => { + it('gets the current branch name', async () => { + const getStdoutFromCommandSpy = jest.spyOn( + miscUtils, + 'getStdoutFromCommand', + ); + + when(getStdoutFromCommandSpy) + .calledWith('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: '/path/to/project', + }) + .mockResolvedValue('release/1.1.1'); + + const branchName = await getCurrentBranchName('/path/to/project'); + + expect(getStdoutFromCommandSpy).toHaveBeenCalledWith( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { cwd: '/path/to/project' }, + ); + + expect(branchName).toBe('release/1.1.1'); + }); + }); + + describe('branchExists', () => { + it('returns true when specified branch name exists', async () => { + const releaseBranchName = 'release/1.0.0'; + when(jest.spyOn(miscUtils, 'getLinesFromCommand')) + .calledWith('git', ['branch', '--list', releaseBranchName], { + cwd: '/path/to/repo', + }) + .mockResolvedValue([releaseBranchName]); + + expect(await branchExists('/path/to/repo', releaseBranchName)).toBe(true); + }); + + it("returns false when specified branch name doesn't exist", async () => { + const releaseBranchName = 'release/1.0.0'; + when(jest.spyOn(miscUtils, 'getLinesFromCommand')) + .calledWith('git', ['branch', '--list', releaseBranchName], { + cwd: '/path/to/repo', + }) + .mockResolvedValue([]); + + expect(await branchExists('/path/to/repo', releaseBranchName)).toBe( + false, + ); + }); + }); + + describe('restoreFiles', () => { + it('should call runCommand with the correct arguments', async () => { + const getStdoutFromCommandSpy = jest.spyOn( + miscUtils, + 'getStdoutFromCommand', + ); + const defaultBranch = 'main'; + when(getStdoutFromCommandSpy) + .calledWith('git', ['merge-base', defaultBranch, 'HEAD'], { + cwd: '/path/to', + }) + .mockResolvedValue('COMMIT_SH'); + const runCommandSpy = jest.spyOn(miscUtils, 'runCommand'); + await restoreFiles('/path/to', defaultBranch, ['packages/filename.ts']); + expect(getStdoutFromCommandSpy).toHaveBeenCalledWith( + 'git', + ['merge-base', defaultBranch, 'HEAD'], + { + cwd: '/path/to', + }, + ); + expect(runCommandSpy).toHaveBeenCalledWith( + 'git', + ['restore', '--source', 'COMMIT_SH', '--', 'packages/filename.ts'], + { + cwd: '/path/to', + }, + ); + }); + }); }); diff --git a/src/repo.ts b/src/repo.ts index 2741101..59011e1 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -16,7 +16,7 @@ const CHANGED_FILE_PATHS_BY_TAG_NAME: Record = {}; * @returns The standard output of the command. * @throws An execa error object if the command fails in some way. */ -async function runGitCommandWithin( +export async function runGitCommandWithin( repositoryDirectoryPath: string, commandName: string, commandArgs: readonly string[], @@ -170,33 +170,86 @@ export async function getRepositoryHttpsUrl( } /** - * This function does three things: + * Commits all changes in a git repository with a specified commit message. * - * 1. Stages all of the changes which have been made to the repo thus far and - * creates a new Git commit which carries the name of the new release. - * 2. Creates a new branch pointed to that commit (which also carries the name - * of the new release). - * 3. Switches to that branch. - * - * @param repositoryDirectoryPath - The path to the repository directory. - * @param args - The arguments. - * @param args.releaseVersion - The release version. + * @param repositoryDirectoryPath - The file system path to the git repository where changes are to be committed. + * @param commitMessage - The message to be used for the git commit. + * @throws If any git command fails to execute. */ -export async function captureChangesInReleaseBranch( +export async function commitAllChanges( repositoryDirectoryPath: string, - { releaseVersion }: { releaseVersion: string }, + commitMessage: string, ) { - await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'checkout', [ - '-b', - `release/${releaseVersion}`, - ]); await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'add', ['-A']); await getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'commit', [ '-m', - `Release ${releaseVersion}`, + commitMessage, ]); } +/** + * Retrieves the current branch name of a git repository. + * + * @param repositoryDirectoryPath - The file system path to the git repository. + * @returns The name of the current branch in the specified repository. + */ +export function getCurrentBranchName(repositoryDirectoryPath: string) { + return getStdoutFromGitCommandWithin(repositoryDirectoryPath, 'rev-parse', [ + '--abbrev-ref', + 'HEAD', + ]); +} + +/** + * Restores specific files in a git repository to their state at the common ancestor commit + * of the current HEAD and the repository's default branch. + * + * This asynchronous function calculates the common ancestor (merge base) of the current HEAD + * and the specified default branch. Then, it uses the `git restore` command to revert the + * specified files back to their state at that ancestor commit. This is useful for undoing + * changes in specific files that have occurred since the branch diverged from the default branch. + * + * @param repositoryDirectoryPath - The file system path to the git repository. + * @param repositoryDefaultBranch - The name of the default branch in the repository. + * @param filePaths - An array of file paths (relative to the repository root) to restore. + */ +export async function restoreFiles( + repositoryDirectoryPath: string, + repositoryDefaultBranch: string, + filePaths: string[], +) { + const ancestorCommitSha = await getStdoutFromGitCommandWithin( + repositoryDirectoryPath, + 'merge-base', + [repositoryDefaultBranch, 'HEAD'], + ); + await runGitCommandWithin(repositoryDirectoryPath, 'restore', [ + '--source', + ancestorCommitSha, + '--', + ...filePaths, + ]); +} + +/** + * Checks if a specific branch exists in the given git repository. + * + * @param repositoryDirectoryPath - The file system path to the git repository. + * @param branchName - The name of the branch to check for existence. + * @returns A promise that resolves to `true` if the branch exists, `false` otherwise. + */ +export async function branchExists( + repositoryDirectoryPath: string, + branchName: string, +) { + const branchNames = await getLinesFromGitCommandWithin( + repositoryDirectoryPath, + 'branch', + ['--list', branchName], + ); + return branchNames.length > 0; +} + /** * 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 diff --git a/src/workflow-operations.test.ts b/src/workflow-operations.test.ts new file mode 100644 index 0000000..29c60b8 --- /dev/null +++ b/src/workflow-operations.test.ts @@ -0,0 +1,161 @@ +import { when } from 'jest-when'; +import { buildMockProject } from '../tests/unit/helpers'; +import { createReleaseBranch } from './workflow-operations'; + +import * as repoModule from './repo'; + +jest.mock('./repo'); + +describe('workflow-operations', () => { + describe('createReleaseBranch', () => { + it('should create a ordinary release branch if it does not exist', async () => { + const project = buildMockProject(); + const newReleaseVersion = `${ + project.releaseVersion.ordinaryNumber + 1 + }.0.0`; + const newReleaseBranchName = `release/${newReleaseVersion}`; + when(jest.spyOn(repoModule, 'getCurrentBranchName')) + .calledWith(project.directoryPath) + .mockResolvedValue('main'); + when(jest.spyOn(repoModule, 'branchExists')) + .calledWith(project.directoryPath, newReleaseBranchName) + .mockResolvedValue(false); + const runGitCommandWithin = jest.spyOn(repoModule, 'runGitCommandWithin'); + + const result = await createReleaseBranch({ + project, + releaseType: 'ordinary', + }); + + expect(result).toStrictEqual({ + version: newReleaseVersion, + firstRun: true, + }); + expect(runGitCommandWithin).toHaveBeenCalledWith( + project.directoryPath, + 'checkout', + ['-b', newReleaseBranchName], + ); + }); + + it('should create a backport release branch if it does not exist', async () => { + const project = buildMockProject(); + const newReleaseVersion = `${project.releaseVersion.ordinaryNumber}.${ + project.releaseVersion.backportNumber + 1 + }.0`; + const newReleaseBranchName = `release/${newReleaseVersion}`; + when(jest.spyOn(repoModule, 'getCurrentBranchName')) + .calledWith(project.directoryPath) + .mockResolvedValue('main'); + when(jest.spyOn(repoModule, 'branchExists')) + .calledWith(project.directoryPath, newReleaseBranchName) + .mockResolvedValue(false); + const runGitCommandWithin = jest.spyOn(repoModule, 'runGitCommandWithin'); + + const result = await createReleaseBranch({ + project, + releaseType: 'backport', + }); + + expect(result).toStrictEqual({ + version: newReleaseVersion, + firstRun: true, + }); + expect(runGitCommandWithin).toHaveBeenCalledWith( + project.directoryPath, + 'checkout', + ['-b', newReleaseBranchName], + ); + }); + + it('should return existing ordinary release branch info if already checked out', async () => { + const project = buildMockProject(); + const newReleaseVersion = `${ + project.releaseVersion.ordinaryNumber + 1 + }.0.0`; + const newReleaseBranchName = `release/${newReleaseVersion}`; + when(jest.spyOn(repoModule, 'getCurrentBranchName')) + .calledWith(project.directoryPath) + .mockResolvedValue(newReleaseBranchName); + + const result = await createReleaseBranch({ + project, + releaseType: 'ordinary', + }); + + expect(result).toStrictEqual({ + version: newReleaseVersion, + firstRun: false, + }); + }); + + it('should return existing backport release branch info if already checked out', async () => { + const project = buildMockProject(); + const newReleaseVersion = `${project.releaseVersion.ordinaryNumber}.${ + project.releaseVersion.backportNumber + 1 + }.0`; + const newReleaseBranchName = `release/${newReleaseVersion}`; + when(jest.spyOn(repoModule, 'getCurrentBranchName')) + .calledWith(project.directoryPath) + .mockResolvedValue(newReleaseBranchName); + + const result = await createReleaseBranch({ + project, + releaseType: 'backport', + }); + + expect(result).toStrictEqual({ + version: newReleaseVersion, + firstRun: false, + }); + }); + + it('should checkout existing ordinary release branch if it already exists', async () => { + const project = buildMockProject(); + const newReleaseVersion = `${ + project.releaseVersion.ordinaryNumber + 1 + }.0.0`; + const newReleaseBranchName = `release/${newReleaseVersion}`; + when(jest.spyOn(repoModule, 'getCurrentBranchName')) + .calledWith(project.directoryPath) + .mockResolvedValue('main'); + when(jest.spyOn(repoModule, 'branchExists')) + .calledWith(project.directoryPath, newReleaseBranchName) + .mockResolvedValue(true); + + const result = await createReleaseBranch({ + project, + releaseType: 'ordinary', + }); + + expect(result).toStrictEqual({ + version: newReleaseVersion, + firstRun: false, + }); + }); + + it('should checkout existing backport release branch if it already exists', async () => { + const project = buildMockProject(); + const newReleaseVersion = `${project.releaseVersion.ordinaryNumber}.${ + project.releaseVersion.backportNumber + 1 + }.0`; + const newReleaseBranchName = `release/${newReleaseVersion}`; + when(jest.spyOn(repoModule, 'getCurrentBranchName')) + .calledWith(project.directoryPath) + .mockResolvedValue('main'); + when(jest.spyOn(repoModule, 'branchExists')) + .calledWith(project.directoryPath, newReleaseBranchName) + .mockResolvedValue(true); + + const result = await createReleaseBranch({ + project, + releaseType: 'backport', + }); + + expect(result).toStrictEqual({ + version: newReleaseVersion, + firstRun: false, + }); + }); + }); +}); diff --git a/src/workflow-operations.ts b/src/workflow-operations.ts new file mode 100644 index 0000000..ee5512f --- /dev/null +++ b/src/workflow-operations.ts @@ -0,0 +1,72 @@ +import { debug } from './misc-utils'; +import { ReleaseType } from './initial-parameters'; +import { Project } from './project'; +import { + branchExists, + getCurrentBranchName, + runGitCommandWithin, +} from './repo'; + +/** + * Creates a new release branch in the given project repository based on the specified release type. + * + * @param args - The arguments. + * @param args.project - Information about the whole project (e.g., names of + * packages and where they can found). + * @param args.releaseType - The type of release ("ordinary" or "backport"), + * which affects how the version is bumped. + * @returns A promise that resolves to an object with the new + * release version and a boolean indicating whether it's the first run. + */ +export async function createReleaseBranch({ + project, + releaseType, +}: { + project: Project; + releaseType: ReleaseType; +}): Promise<{ + version: string; + firstRun: boolean; +}> { + const newReleaseVersion = + releaseType === 'backport' + ? `${project.releaseVersion.ordinaryNumber}.${ + project.releaseVersion.backportNumber + 1 + }.0` + : `${project.releaseVersion.ordinaryNumber + 1}.0.0`; + + const releaseBranchName = `release/${newReleaseVersion}`; + + const currentBranchName = await getCurrentBranchName(project.directoryPath); + + if (currentBranchName === releaseBranchName) { + debug(`Already on ${releaseBranchName} branch.`); + return { + version: newReleaseVersion, + firstRun: false, + }; + } + + if (await branchExists(project.directoryPath, releaseBranchName)) { + debug( + `Current release branch already exists. Checking out the existing branch.`, + ); + await runGitCommandWithin(project.directoryPath, 'checkout', [ + releaseBranchName, + ]); + return { + version: newReleaseVersion, + firstRun: false, + }; + } + + await runGitCommandWithin(project.directoryPath, 'checkout', [ + '-b', + releaseBranchName, + ]); + + return { + version: newReleaseVersion, + firstRun: true, + }; +} diff --git a/tests/functional/helpers/remote-repo.ts b/tests/functional/helpers/remote-repo.ts index 2a3c1e7..acce82f 100644 --- a/tests/functional/helpers/remote-repo.ts +++ b/tests/functional/helpers/remote-repo.ts @@ -14,7 +14,7 @@ export default class RemoteRepo extends Repo { await fs.promises.mkdir(this.getWorkingDirectoryPath(), { recursive: true, }); - await this.runCommand('git', ['init', '--bare']); + await this.runCommand('git', ['init', '-b', 'main', '--bare']); } /** diff --git a/yarn.lock b/yarn.lock index c6f9631..15d6330 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1016,6 +1016,7 @@ __metadata: jest-it-up: ^2.0.2 jest-when: ^3.5.2 nanoid: ^3.3.4 + outdent: ^0.8.0 pony-cause: ^2.1.9 prettier: ^2.2.1 prettier-plugin-packagejson: ^2.3.0 @@ -4836,6 +4837,13 @@ __metadata: languageName: node linkType: hard +"outdent@npm:^0.8.0": + version: 0.8.0 + resolution: "outdent@npm:0.8.0" + checksum: 72b7c1a287674317ea477999ec24e73a9eda21de35eb9429218f4a5bab899e964afaee7508265898118fee5cbee1d79397916b66dd8aeee285cd948ea5b1f562 + languageName: node + linkType: hard + "p-limit@npm:^1.1.0": version: 1.3.0 resolution: "p-limit@npm:1.3.0"