Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 52 additions & 15 deletions tools/release/cherry-pick.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fetch from 'node-fetch';
import readline from 'readline';

import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';

const REPO = 'WordPress/gutenberg';
const LABEL = process.argv[ 2 ] || 'Backport to WP Beta/RC';
Expand Down Expand Up @@ -197,15 +198,16 @@ function getGitHubAuthToken() {
* success since the last attempt.
*
* @param {Object[]} PRs The list of PRs to cherry-pick.
* @param {string} cwd The repository directory.
* @return {Array} A two-tuple containing a list of successful cherry-picks and a list of failed ones.
*/
function cherryPickAll( PRs ) {
function cherryPickAll( PRs, cwd = process.cwd() ) {
let remainingPRs = [ ...PRs ];
let i = 1;
let allSuccesses = [];
while ( remainingPRs.length ) {
console.log( `Cherry-picking round ${ i++ }: ` );
const [ successes, failures ] = cherryPickRound( remainingPRs );
const [ successes, failures ] = cherryPickRound( remainingPRs, cwd );
allSuccesses = [ ...allSuccesses, ...successes ];
remainingPRs = failures;
if ( ! successes.length ) {
Expand All @@ -224,16 +226,17 @@ function cherryPickAll( PRs ) {
* Processes every PR once.
*
* @param {Object[]} PRs The list of PRs to cherry-pick.
* @param {string} cwd The repository directory.
* @return {Array} A two-tuple containing a list of successful cherry-picks and a list of failed ones.
*/
function cherryPickRound( PRs ) {
function cherryPickRound( PRs, cwd ) {
const stack = [ ...PRs ];
const successes = [];
const failures = [];
while ( stack.length ) {
const PR = stack.shift();
try {
const cherryPickHash = cherryPickOne( PR.mergeCommitHash );
const cherryPickHash = cherryPickOne( PR.mergeCommitHash, cwd );
successes.push( {
...PR,
cherryPickHash,
Expand Down Expand Up @@ -307,20 +310,47 @@ function indent( text, width = 3 ) {
* Attempts to cherry-pick a given commit into the current branch,
*
* @param {string} commit A commit hash.
* @param {string} cwd The repository directory.
* @return {string} Branch name.
*/
function cherryPickOne( commit ) {
const result = spawnSync( 'git', [ 'cherry-pick', commit ] );
const message = result.stdout.toString().trim();
if ( result.status !== 0 || ! message.includes( 'Author: ' ) ) {
spawnSync( 'git', [ 'reset', '--hard' ] );
function cherryPickOne( commit, cwd = process.cwd() ) {
const headBeforeCherryPick = spawnSync( 'git', [ 'rev-parse', 'HEAD' ], {
cwd,
} )
.stdout.toString()
.trim();
const result = spawnSync( 'git', [ 'cherry-pick', commit ], { cwd } );
if ( result.status !== 0 ) {
spawnSync( 'git', [ 'cherry-pick', '--abort' ], { cwd } );

const headAfterCleanup = spawnSync( 'git', [ 'rev-parse', 'HEAD' ], {
cwd,
} )
.stdout.toString()
.trim();
const cherryPickState = spawnSync(
'git',
[ 'rev-parse', '--verify', '--quiet', 'CHERRY_PICK_HEAD' ],
{ cwd }
);
if (
headAfterCleanup !== headBeforeCherryPick ||
cherryPickState.status === 0
) {
console.error(
`Failed to restore the repository after cherry-picking ${ commit }. ` +
'Nothing was pushed. Restore the repository before running this script again.'
);
process.exit( 1 );
}

throw new Error( result.stderr.toString().trim() );
}
const commitHashOutput = spawnSync( 'git', [
'rev-parse',
'--short',
'HEAD',
] );
const commitHashOutput = spawnSync(
'git',
[ 'rev-parse', '--short', 'HEAD' ],
{ cwd }
);
return commitHashOutput.stdout.toString().trim();
}

Expand Down Expand Up @@ -548,4 +578,11 @@ async function promptDoYouWantToProceed() {
rl.close();
}

main();
if (
process.argv[ 1 ] &&
import.meta.url === pathToFileURL( process.argv[ 1 ] ).href
) {
main();
}

export { cherryPickAll, cherryPickOne };
258 changes: 258 additions & 0 deletions tools/release/test/cherry-pick.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* External dependencies
*/
const fs = require( 'node:fs' );
const os = require( 'node:os' );
const path = require( 'node:path' );
const { spawnSync } = require( 'node:child_process' );

/**
* Internal dependencies
*/
const { cherryPickAll, cherryPickOne } = require( '../cherry-pick.mjs' );

const LOCAL_AUTHOR = {
name: 'Marco Ciampini',
email: 'marco@example.com',
};

const OTHER_AUTHOR = {
name: 'Other Author',
email: 'other@example.com',
};

let repository;

function git( args, options = {} ) {
const result = spawnSync( 'git', args, {
cwd: repository,
encoding: 'utf8',
env: {
...process.env,
...options.env,
},
} );

if ( ! options.allowFailure && result.status !== 0 ) {
throw new Error( result.stderr );
}

return result;
}

function commitFile( file, contents, message, author = LOCAL_AUTHOR ) {
fs.writeFileSync( path.join( repository, file ), contents );
git( [ 'add', file ] );
git( [ 'commit', '-m', message ], {
env: {
GIT_AUTHOR_NAME: author.name,
GIT_AUTHOR_EMAIL: author.email,
},
} );
return git( [ 'rev-parse', 'HEAD' ] ).stdout.trim();
}

function initializeRepository() {
repository = fs.mkdtempSync(
path.join( os.tmpdir(), 'gutenberg-cherry-pick-test-' )
);
git( [ 'init', '--initial-branch=target' ] );
git( [ 'config', 'user.name', LOCAL_AUTHOR.name ] );
git( [ 'config', 'user.email', LOCAL_AUTHOR.email ] );
commitFile( 'base.txt', 'base\n', 'Base' );
}

describe( 'release cherry-picks', () => {
beforeEach( initializeRepository );

afterEach( () => {
fs.rmSync( repository, { force: true, recursive: true } );
} );

it( 'records a successful cherry-pick whose output includes an Author line', () => {
git( [ 'switch', '-c', 'source' ] );
const commit = commitFile(
'change.txt',
'change\n',
'Different author',
OTHER_AUTHOR
);
git( [ 'switch', 'target' ] );

expect( cherryPickOne( commit, repository ) ).toBe(
git( [ 'rev-parse', '--short', 'HEAD' ] ).stdout.trim()
);
expect( git( [ 'log', '-1', '--format=%s' ] ).stdout.trim() ).toBe(
'Different author'
);
} );

it( 'records a successful cherry-pick whose output omits an Author line', () => {
git( [ 'switch', '-c', 'source' ] );
const commit = commitFile(
'change.txt',
'change\n',
'Matching author'
);
git( [ 'switch', 'target' ] );

expect( cherryPickOne( commit, repository ) ).toBe(
git( [ 'rev-parse', '--short', 'HEAD' ] ).stdout.trim()
);
expect( git( [ 'log', '-1', '--format=%s' ] ).stdout.trim() ).toBe(
'Matching author'
);
} );

it( 'restores the pre-attempt state after a conflicting cherry-pick', () => {
git( [ 'switch', '-c', 'source' ] );
const commit = commitFile(
'base.txt',
'source\n',
'Conflicting source change'
);
git( [ 'switch', 'target' ] );
commitFile( 'base.txt', 'target\n', 'Conflicting target change' );
const headBeforeCherryPick = git( [
'rev-parse',
'HEAD',
] ).stdout.trim();

expect( () => cherryPickOne( commit, repository ) ).toThrow(
/CONFLICT|could not apply/
);
expect( git( [ 'rev-parse', 'HEAD' ] ).stdout.trim() ).toBe(
headBeforeCherryPick
);
expect(
fs.readFileSync( path.join( repository, 'base.txt' ), 'utf8' )
).toBe( 'target\n' );
expect( git( [ 'status', '--porcelain' ] ).stdout ).toBe( '' );
expect(
git( [ 'rev-parse', '--verify', '--quiet', 'CHERRY_PICK_HEAD' ], {
allowFailure: true,
} ).status
).not.toBe( 0 );
} );

it( 'preserves unrelated work when cleaning up a conflict', () => {
commitFile( 'unrelated.txt', 'committed\n', 'Add unrelated file' );
git( [ 'switch', '-c', 'source' ] );
const commit = commitFile(
'base.txt',
'source\n',
'Conflicting source change'
);
git( [ 'switch', 'target' ] );
commitFile( 'base.txt', 'target\n', 'Conflicting target change' );
const headBeforeCherryPick = git( [
'rev-parse',
'HEAD',
] ).stdout.trim();
fs.writeFileSync(
path.join( repository, 'unrelated.txt' ),
'local work\n'
);

expect( () => cherryPickOne( commit, repository ) ).toThrow(
/CONFLICT|could not apply/
);
expect( git( [ 'rev-parse', 'HEAD' ] ).stdout.trim() ).toBe(
headBeforeCherryPick
);
expect(
fs.readFileSync( path.join( repository, 'unrelated.txt' ), 'utf8' )
).toBe( 'local work\n' );
expect( git( [ 'status', '--porcelain' ] ).stdout ).toBe(
' M unrelated.txt\n'
);
expect(
git( [ 'rev-parse', '--verify', '--quiet', 'CHERRY_PICK_HEAD' ], {
allowFailure: true,
} ).status
).not.toBe( 0 );
} );

it( 'does not retry a successful cherry-pick that omits an Author line', () => {
git( [ 'switch', '-c', 'matching-author' ] );
const matchingAuthorCommit = commitFile(
'matching-author.txt',
'matching\n',
'Matching author'
);
git( [ 'switch', 'target' ] );
git( [ 'switch', '-c', 'different-author' ] );
const differentAuthorCommit = commitFile(
'different-author.txt',
'different\n',
'Different author',
OTHER_AUTHOR
);
git( [ 'switch', 'target' ] );

const [ successes, failures ] = cherryPickAll(
[
{
mergeCommitHash: matchingAuthorCommit,
number: 1,
title: 'Matching author',
},
{
mergeCommitHash: differentAuthorCommit,
number: 2,
title: 'Different author',
},
],
repository
);
expect( console ).toHaveLogged();

expect( successes.map( ( { number } ) => number ) ).toEqual( [ 1, 2 ] );
expect( failures ).toEqual( [] );
expect(
git( [ 'log', '--format=%s' ] )
.stdout.split( '\n' )
.filter( ( subject ) => subject === 'Matching author' )
).toHaveLength( 1 );
} );

it( 'retries a failed cherry-pick after another commit succeeds', () => {
git( [ 'switch', '-c', 'source' ] );
const prerequisiteCommit = commitFile(
'base.txt',
'middle\n',
'Prerequisite',
OTHER_AUTHOR
);
const dependentCommit = commitFile(
'base.txt',
'final\n',
'Dependent',
OTHER_AUTHOR
);
git( [ 'switch', 'target' ] );

const [ successes, failures ] = cherryPickAll(
[
{
mergeCommitHash: dependentCommit,
number: 2,
title: 'Dependent',
},
{
mergeCommitHash: prerequisiteCommit,
number: 1,
title: 'Prerequisite',
},
],
repository
);
expect( console ).toHaveLogged();

expect( successes.map( ( { number } ) => number ) ).toEqual( [ 1, 2 ] );
expect( failures ).toEqual( [] );
expect(
fs.readFileSync( path.join( repository, 'base.txt' ), 'utf8' )
).toBe( 'final\n' );
} );
} );
Loading