From 089807e2bc15dc4dd61b1d8735e5e55b93bc9be9 Mon Sep 17 00:00:00 2001 From: Kamil Piechaczek Date: Fri, 26 Jun 2020 15:43:42 +0200 Subject: [PATCH] Support for collecting commits from branches other than master. --- .../generatechangelogformonorepository.js | 5 +- .../lib/release-tools/utils/getcommits.js | 113 ++++-- .../tests/release-tools/utils/getcommits.js | 356 +++++++++++++----- 3 files changed, 334 insertions(+), 140 deletions(-) diff --git a/packages/ckeditor5-dev-env/lib/release-tools/tasks/generatechangelogformonorepository.js b/packages/ckeditor5-dev-env/lib/release-tools/tasks/generatechangelogformonorepository.js index e059d0b10..37cbcb616 100644 --- a/packages/ckeditor5-dev-env/lib/release-tools/tasks/generatechangelogformonorepository.js +++ b/packages/ckeditor5-dev-env/lib/release-tools/tasks/generatechangelogformonorepository.js @@ -50,6 +50,8 @@ const noteInfo = `[ℹ️](${ VERSIONING_POLICY_URL }#major-and-minor-breaking-c * * @param {Boolean} [options.collaborationFeatures=false] Whether to add a note about collaboration features. * + * @param {String} [options.releaseBranch='master'] A name of the branch that should be used for releasing packages. + * * @returns {Promise} */ module.exports = function generateChangelogForMonoRepository( options ) { @@ -102,7 +104,8 @@ module.exports = function generateChangelogForMonoRepository( options ) { const packagesPaths = new Map(); const commitOptions = { - from: options.from ? options.from : 'v' + pkgJson.version + from: options.from ? options.from : 'v' + pkgJson.version, + releaseBranch: options.releaseBranch }; return getCommits( transformCommit, commitOptions ) diff --git a/packages/ckeditor5-dev-env/lib/release-tools/utils/getcommits.js b/packages/ckeditor5-dev-env/lib/release-tools/utils/getcommits.js index 14ef696ba..7cd944c24 100644 --- a/packages/ckeditor5-dev-env/lib/release-tools/utils/getcommits.js +++ b/packages/ckeditor5-dev-env/lib/release-tools/utils/getcommits.js @@ -10,6 +10,7 @@ const conventionalCommitsFilter = require( 'conventional-commits-filter' ); const gitRawCommits = require( 'git-raw-commits' ); const concat = require( 'concat-stream' ); const parserOptions = require( './parseroptions' ); +const { tools } = require( '@ckeditor/ckeditor5-dev-utils' ); /** * Returns a promise that resolves an array of commits since the last tag specified as `options.from`. @@ -17,49 +18,87 @@ const parserOptions = require( './parseroptions' ); * @param {Function} transformCommit * @param {Object} options * @param {String} [options.from] A commit or tag name that will be the first param of the range of commits to collect. + * @param {String} [options.releaseBranch='master'] A name of the branch that should be used for releasing packages. + * @param {String} [options.mainBranch='master'] A name of the main branch in the repository. * @returns {Promise.>} */ module.exports = function getCommits( transformCommit, options = {} ) { - const gitRawCommitsOpts = { - format: '%B%n-hash-%n%H', - from: options.from, - merges: undefined, - firstParent: true - }; + const releaseBranch = options.releaseBranch || 'master'; + const mainBranch = options.mainBranch || 'master'; - return new Promise( ( resolve, reject ) => { - const stream = gitRawCommits( gitRawCommitsOpts ) - .on( 'error', err => { - /* istanbul ignore else */ - if ( err.message.match( /'HEAD': unknown/ ) ) { - reject( new Error( 'Given repository is empty.' ) ); - } else if ( err.message.match( new RegExp( `'${ options.from }\\.\\.HEAD': unknown` ) ) ) { - reject( new Error( - `Cannot find tag or commit "${ options.from }" in given repository.` - ) ); - } else { - reject( err ); - } - } ); + const currentBranch = exec( 'git rev-parse --abbrev-ref HEAD' ).trim(); - stream.pipe( conventionalCommitsParser( parserOptions ) ) - .pipe( concat( data => { - const commits = conventionalCommitsFilter( data ) - .map( commit => transformCommit( commit ) ) - .reduce( ( allCommits, commit ) => { - if ( Array.isArray( commit ) ) { - allCommits.push( ...commit ); - } else { - allCommits.push( commit ); - } + // Check whether current branch is the same as the release branch. + if ( currentBranch !== releaseBranch ) { + return Promise.reject( new Error( + `Expected to be checked out on the release branch ("${ releaseBranch }") instead of "${ currentBranch }". Aborting.` + ) ); + } - return allCommits; - }, [] ) - .filter( commit => commit ); + // If the release branch is the same as the main branch, we can collect all commits directly from the branch. + if ( releaseBranch === mainBranch ) { + return findCommits( { from: options.from } ); + } else { + // Otherwise, (release branch is other than the main branch) we need to merge arrays of commits. + // See: https://github.com/ckeditor/ckeditor5/issues/7492. + const baseCommit = exec( `git merge-base ${ releaseBranch } ${ mainBranch }` ).trim(); - stream.destroy(); + const commitPromises = [ + // 1. Commits from the last release and to the point where the release branch was created (the merge-base commit). + findCommits( { from: options.from, to: baseCommit } ), + // 2. Commits from the merge-base commit to HEAD. + findCommits( { from: baseCommit } ) + ]; - return resolve( commits ); - } ) ); - } ); + return Promise.all( commitPromises ) + .then( commits => [].concat( ...commits ) ); + } + + function findCommits( gitRawCommitsOptions ) { + const gitRawCommitsOpts = Object.assign( {}, gitRawCommitsOptions, { + format: '%B%n-hash-%n%H', + merges: undefined, + firstParent: true + } ); + + return new Promise( ( resolve, reject ) => { + const stream = gitRawCommits( gitRawCommitsOpts ) + .on( 'error', err => { + /* istanbul ignore else */ + if ( err.message.match( /'HEAD': unknown/ ) ) { + reject( new Error( 'Given repository is empty.' ) ); + } else if ( err.message.match( new RegExp( `'${ options.from }\\.\\.HEAD': unknown` ) ) ) { + reject( new Error( + `Cannot find tag or commit "${ options.from }" in given repository.` + ) ); + } else { + reject( err ); + } + } ); + + stream.pipe( conventionalCommitsParser( parserOptions ) ) + .pipe( concat( data => { + const commits = conventionalCommitsFilter( data ) + .map( commit => transformCommit( commit ) ) + .reduce( ( allCommits, commit ) => { + if ( Array.isArray( commit ) ) { + allCommits.push( ...commit ); + } else { + allCommits.push( commit ); + } + + return allCommits; + }, [] ) + .filter( commit => commit ); + + stream.destroy(); + + return resolve( commits ); + } ) ); + } ); + } + + function exec( command ) { + return tools.shExec( command, { verbosity: 'error' } ); + } }; diff --git a/packages/ckeditor5-dev-env/tests/release-tools/utils/getcommits.js b/packages/ckeditor5-dev-env/tests/release-tools/utils/getcommits.js index 17380bca8..cc679c703 100644 --- a/packages/ckeditor5-dev-env/tests/release-tools/utils/getcommits.js +++ b/packages/ckeditor5-dev-env/tests/release-tools/utils/getcommits.js @@ -9,13 +9,13 @@ const fs = require( 'fs' ); const path = require( 'path' ); const sinon = require( 'sinon' ); const expect = require( 'chai' ).expect; +const mockery = require( 'mockery' ); const { tools } = require( '@ckeditor/ckeditor5-dev-utils' ); -const getCommits = require( '../../../lib/release-tools/utils/getcommits' ); describe( 'dev-env/release-tools/utils', () => { - let tmpCwd, cwd, transformCommit; + let tmpCwd, cwd, stubs, sandbox, getCommits; - describe( 'getNewReleaseType()', () => { + describe( 'getCommits()', () => { before( () => { cwd = process.cwd(); tmpCwd = fs.mkdtempSync( __dirname + path.sep ); @@ -35,135 +35,287 @@ describe( 'dev-env/release-tools/utils', () => { exec( 'git config user.name "CKEditor5 CI"' ); } - // Do not modify the commit. - transformCommit = commit => commit; + sandbox = sinon.createSandbox(); + + mockery.enable( { + useCleanCache: true, + warnOnReplace: false, + warnOnUnregistered: false + } ); + + stubs = { + shExec: sandbox.stub(), + gitRawCommits: sandbox.stub() + }; + + // Other kinds of mocking the `git-raw-commits` package end with an error. + // But this way it works. + stubs.gitRawCommits.callsFake( options => { + const modulePath = require.resolve( 'git-raw-commits' ); + + return require( modulePath )( options ); + } ); + + mockery.registerMock( '@ckeditor/ckeditor5-dev-utils', { + tools: { + shExec: stubs.shExec + } + } ); + + mockery.registerMock( 'git-raw-commits', stubs.gitRawCommits ); + + getCommits = require( '../../../lib/release-tools/utils/getcommits' ); } ); afterEach( () => { process.chdir( cwd ); exec( `rm -rf ${ path.join( tmpCwd, '.git' ) }` ); - } ); - it( 'throws an error when repository is empty', () => { - return getCommits( transformCommit ) - .then( - () => { - throw new Error( 'Supposed to be rejected.' ); - }, - err => { - expect( err.message ).to.equal( 'Given repository is empty.' ); - } - ); + sandbox.restore(); + mockery.disable(); } ); - it( 'throws an error when repository is empty', () => { - return getCommits( transformCommit, { from: 'foobar' } ) - .then( - () => { - throw new Error( 'Supposed to be rejected.' ); - }, - err => { - expect( err.message ).to.equal( 'Cannot find tag or commit "foobar" in given repository.' ); - } - ); - } ); + describe( 'branch for releasing is the same as the main branch', () => { + beforeEach( () => { + stubs.shExec.onFirstCall().returns( 'master\n' ); + } ); - it( 'returns an array of commits after "git init"', () => { - exec( 'git commit --allow-empty --message "First."' ); - exec( 'git commit --allow-empty --message "Second."' ); + it( 'throws an error when the specified release branch is not equal to the current checked out branch', () => { + return getCommits( transformCommit, { releaseBranch: 'release' } ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + err => { + expect( err.message ).to.equal( + 'Expected to be checked out on the release branch ("release") instead of "master". Aborting.' + ); + } + ); + } ); - return getCommits( transformCommit ) - .then( commits => { - expect( commits.length ).to.equal( 2 ); - expect( commits[ 0 ].header ).to.equal( 'Second.' ); - expect( commits[ 1 ].header ).to.equal( 'First.' ); - } ); - } ); + it( 'throws an error when the default release branch is not equal to the current checked out branch', () => { + stubs.shExec.reset(); + stubs.shExec.onFirstCall().returns( 'release\n' ); - it( 'returns an array of commits after "git init" if `options.from` is not specified', () => { - exec( 'git commit --allow-empty --message "First."' ); - exec( 'git commit --allow-empty --message "Second."' ); - exec( 'git tag v1.0.0' ); - exec( 'git commit --allow-empty --message "Third."' ); - exec( 'git commit --allow-empty --message "Fourth."' ); - - return getCommits( transformCommit ) - .then( commits => { - expect( commits.length ).to.equal( 4 ); - expect( commits[ 0 ].header ).to.equal( 'Fourth.' ); - expect( commits[ 1 ].header ).to.equal( 'Third.' ); - expect( commits[ 2 ].header ).to.equal( 'Second.' ); - expect( commits[ 3 ].header ).to.equal( 'First.' ); - } ); - } ); + return getCommits( transformCommit ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + err => { + expect( err.message ).to.equal( + 'Expected to be checked out on the release branch ("master") instead of "release". Aborting.' + ); + } + ); + } ); - it( 'returns an array of commits since last tag (`options.from` is specified)', () => { - exec( 'git commit --allow-empty --message "First."' ); - exec( 'git commit --allow-empty --message "Second."' ); - exec( 'git tag v1.0.0' ); - exec( 'git commit --allow-empty --message "Third."' ); - exec( 'git commit --allow-empty --message "Fourth."' ); - - return getCommits( transformCommit, { from: 'v1.0.0' } ) - .then( commits => { - expect( commits.length ).to.equal( 2 ); - expect( commits[ 0 ].header ).to.equal( 'Fourth.' ); - expect( commits[ 1 ].header ).to.equal( 'Third.' ); - } ); - } ); + it( 'throws an error when repository is empty', () => { + return getCommits( transformCommit ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + err => { + expect( err.message ).to.equal( 'Given repository is empty.' ); + } + ); + } ); - it( 'returns an array of commits since specified commit (`options.from` is specified)', () => { - exec( 'git commit --allow-empty --message "First."' ); - exec( 'git commit --allow-empty --message "Second."' ); - exec( 'git tag v1.0.0' ); - exec( 'git commit --allow-empty --message "Third."' ); + it( 'throws an error when repository is empty', () => { + return getCommits( transformCommit, { from: 'foobar' } ) + .then( + () => { + throw new Error( 'Supposed to be rejected.' ); + }, + err => { + expect( err.message ).to.equal( 'Cannot find tag or commit "foobar" in given repository.' ); + } + ); + } ); - const commitId = exec( 'git rev-parse HEAD' ).trim(); + it( 'returns an array of commits after "git init"', () => { + exec( 'git commit --allow-empty --message "First."' ); + exec( 'git commit --allow-empty --message "Second."' ); - exec( 'git commit --allow-empty --message "Fourth."' ); + return getCommits( transformCommit ) + .then( commits => { + expect( commits.length ).to.equal( 2 ); + expect( commits[ 0 ].header ).to.equal( 'Second.' ); + expect( commits[ 1 ].header ).to.equal( 'First.' ); + } ); + } ); - return getCommits( transformCommit, { from: commitId } ) - .then( commits => { - expect( commits.length ).to.equal( 1 ); - expect( commits[ 0 ].header ).to.equal( 'Fourth.' ); - } ); - } ); + it( 'returns an array of commits after "git init" (main branch is not equal to "master")', () => { + stubs.shExec.reset(); + stubs.shExec.onFirstCall().returns( 'main-branch\n' ); - it( 'ignores false values returned by the "transformCommit" mapper', () => { - const transformCommit = sinon.stub(); + exec( 'git checkout -b main-branch' ); + exec( 'git commit --allow-empty --message "First."' ); + exec( 'git commit --allow-empty --message "Second."' ); - transformCommit.onFirstCall().callsFake( commit => commit ); - transformCommit.onSecondCall().callsFake( () => null ); + return getCommits( transformCommit, { mainBranch: 'main-branch', releaseBranch: 'main-branch' } ) + .then( commits => { + expect( commits.length ).to.equal( 2 ); + expect( commits[ 0 ].header ).to.equal( 'Second.' ); + expect( commits[ 1 ].header ).to.equal( 'First.' ); + } ); + } ); - exec( 'git commit --allow-empty --message "First."' ); - exec( 'git commit --allow-empty --message "Second."' ); + it( 'returns an array of commits after "git init" if `options.from` is not specified', () => { + exec( 'git commit --allow-empty --message "First."' ); + exec( 'git commit --allow-empty --message "Second."' ); + exec( 'git tag v1.0.0' ); + exec( 'git commit --allow-empty --message "Third."' ); + exec( 'git commit --allow-empty --message "Fourth."' ); - return getCommits( transformCommit ) - .then( commits => { - expect( commits.length ).to.equal( 1 ); - expect( commits[ 0 ].header ).to.equal( 'Second.' ); - } ); - } ); + return getCommits( transformCommit ) + .then( commits => { + expect( commits.length ).to.equal( 4 ); + expect( commits[ 0 ].header ).to.equal( 'Fourth.' ); + expect( commits[ 1 ].header ).to.equal( 'Third.' ); + expect( commits[ 2 ].header ).to.equal( 'Second.' ); + expect( commits[ 3 ].header ).to.equal( 'First.' ); + } ); + } ); - it( 'handles arrays returned by the "transformCommit" mapper', () => { - const transformCommit = sinon.stub(); + it( 'returns an array of commits since last tag (`options.from` is specified)', () => { + exec( 'git commit --allow-empty --message "First."' ); + exec( 'git commit --allow-empty --message "Second."' ); + exec( 'git tag v1.0.0' ); + exec( 'git commit --allow-empty --message "Third."' ); + exec( 'git commit --allow-empty --message "Fourth."' ); - transformCommit.onFirstCall().callsFake( commit => { - return [ commit, commit ]; + return getCommits( transformCommit, { from: 'v1.0.0' } ) + .then( commits => { + expect( commits.length ).to.equal( 2 ); + expect( commits[ 0 ].header ).to.equal( 'Fourth.' ); + expect( commits[ 1 ].header ).to.equal( 'Third.' ); + } ); } ); - exec( 'git commit --allow-empty --message "First."' ); + it( 'returns an array of commits since specified commit (`options.from` is specified)', () => { + exec( 'git commit --allow-empty --message "First."' ); + exec( 'git commit --allow-empty --message "Second."' ); + exec( 'git tag v1.0.0' ); + exec( 'git commit --allow-empty --message "Third."' ); - return getCommits( transformCommit ) - .then( commits => { - expect( commits.length ).to.equal( 2 ); - expect( commits[ 0 ].header ).to.equal( 'First.' ); - expect( commits[ 1 ].header ).to.equal( 'First.' ); + const commitId = exec( 'git rev-parse HEAD' ).trim(); + + exec( 'git commit --allow-empty --message "Fourth."' ); + + return getCommits( transformCommit, { from: commitId } ) + .then( commits => { + expect( commits.length ).to.equal( 1 ); + expect( commits[ 0 ].header ).to.equal( 'Fourth.' ); + } ); + } ); + + it( 'ignores false values returned by the "transformCommit" mapper', () => { + const transformCommit = sinon.stub(); + + transformCommit.onFirstCall().callsFake( commit => commit ); + transformCommit.onSecondCall().callsFake( () => null ); + + exec( 'git commit --allow-empty --message "First."' ); + exec( 'git commit --allow-empty --message "Second."' ); + + return getCommits( transformCommit ) + .then( commits => { + expect( commits.length ).to.equal( 1 ); + expect( commits[ 0 ].header ).to.equal( 'Second.' ); + } ); + } ); + + it( 'handles arrays returned by the "transformCommit" mapper', () => { + const transformCommit = sinon.stub(); + + transformCommit.onFirstCall().callsFake( commit => { + return [ commit, commit ]; } ); + + exec( 'git commit --allow-empty --message "First."' ); + + return getCommits( transformCommit ) + .then( commits => { + expect( commits.length ).to.equal( 2 ); + expect( commits[ 0 ].header ).to.equal( 'First.' ); + expect( commits[ 1 ].header ).to.equal( 'First.' ); + } ); + } ); + } ); + + describe( 'branch for releasing is other than the main branch', () => { + it( 'collects commits from the main branch and the release branch', () => { + stubs.shExec.onFirstCall().returns( 'release\n' ); + stubs.shExec.onSecondCall().callsFake( exec ); + + exec( 'git commit --allow-empty --message "Type: master: 1."' ); + exec( 'git tag v1.0.0' ); + + // Commits on master and release branches will be parsed. + exec( 'git commit --allow-empty --message "Type: master: 2."' ); + exec( 'git commit --allow-empty --message "Type: master: 3."' ); + exec( 'git commit --allow-empty --message "Type: master: 4."' ); + + exec( 'git checkout -b i/100' ); + exec( 'git commit --allow-empty --message "Type: i/100: 1."' ); + exec( 'git commit --allow-empty --message "Type: i/100: 2."' ); + exec( 'git checkout master' ); + exec( 'git merge i/100 --no-ff --message "Type: Merge i/100. master: 5"' ); + + exec( 'git checkout -b i/200' ); + exec( 'git commit --allow-empty --message "Type: i/200: 1."' ); + exec( 'git commit --allow-empty --message "Type: i/200: 2."' ); + exec( 'git commit --allow-empty --message "Type: i/200: 3."' ); + exec( 'git checkout master' ); + exec( 'git merge i/200 --no-ff --message "Type: Merge i/200. master: 6"' ); + + const baseCommit = exec( 'git rev-parse HEAD' ).trim(); + + exec( 'git checkout -b release' ); + exec( 'git commit --allow-empty --message "Type: release: 1, master 7."' ); + + exec( 'git checkout -b i/300' ); + exec( 'git commit --allow-empty --message "Type: i/300: 1."' ); + exec( 'git commit --allow-empty --message "Type: i/300: 2."' ); + exec( 'git checkout release' ); + exec( 'git merge i/300 --no-ff --message "Type: Merge i/300. release: 2, master: 8"' ); + + exec( 'git commit --allow-empty --message "Type: release: 3, master 9."' ); + exec( 'git branch -D i/100 i/200 i/300' ); + + return getCommits( transformCommit, { from: 'v1.0.0', releaseBranch: 'release' } ) + .then( commits => { + expect( commits.length ).to.equal( 8 ); + + expect( stubs.gitRawCommits.firstCall.args[ 0 ] ).to.deep.equal( { + from: 'v1.0.0', + to: baseCommit, + format: '%B%n-hash-%n%H', + merges: undefined, + firstParent: true + } ); + + expect( stubs.gitRawCommits.secondCall.args[ 0 ] ).to.deep.equal( { + to: 'HEAD', + from: baseCommit, + format: '%B%n-hash-%n%H', + merges: undefined, + firstParent: true + } ); + } ); + } ); } ); } ); function exec( command ) { return tools.shExec( command, { verbosity: 'error' } ); } + + // Do not modify the commit. + function transformCommit( commit ) { + return commit; + } } );