|
<?php |
|
|
|
class WP_CLI_Trac_Patched_Git_Branch extends WP_CLI_Command { |
|
|
|
/** |
|
* Create Git branch from Core Trac patches, or amend an existing branch with the latest patches. |
|
* |
|
* ## OPTIONS |
|
* |
|
* <ticket> |
|
* : The ticket that patches will be applied from. If you are on master, it will checkout trac-<ticket> branch. |
|
* |
|
* ## EXAMPLES |
|
* |
|
* wp git-apply-core-trac-patches 12345 |
|
* |
|
* @when before_wp_load |
|
* @synopsis <ticket> |
|
* @param array $args |
|
* @param array $assoc_args |
|
*/ |
|
function __invoke( $args, $assoc_args ) { |
|
|
|
$ticket = array_shift( $args ); |
|
if ( ! preg_match( '/^\d+$/', $ticket ) ) { |
|
WP_CLI::error( 'Expected ticket arg to be integer.' ); |
|
} |
|
if ( 'commit' !== trim( shell_exec( 'git cat-file -t 5190215645328199b11a076bd718461d941a6ec3' ) ) ) { |
|
WP_CLI::error( 'This repo does not appear to be a clone of develop.git.wordpress.org' ); |
|
} |
|
$git_root_level = trim( shell_exec( 'git rev-parse --show-toplevel' ) ); |
|
if ( empty( $git_root_level ) || ! is_dir( $git_root_level ) ) { |
|
WP_CLI::error( 'Unable to find Git root directory.' ); |
|
} |
|
chdir( $git_root_level ); |
|
|
|
system( 'git diff-index --quiet HEAD', $return_var ); |
|
if ( 0 !== $return_var ) { |
|
WP_CLI::error( 'git repo state dirty. Stash changes?' ); |
|
} |
|
|
|
$current_branch = trim( shell_exec( 'git rev-parse --abbrev-ref HEAD' ) ); |
|
if ( 'master' === $current_branch ) { |
|
WP_CLI::error( sprintf( 'You cannot apply patches to master. First checkout a new branch, e.g. git checkout -b trac-%d', $ticket ) ); |
|
} |
|
|
|
$trac_ticket_url = sprintf( 'https://core.trac.wordpress.org/ticket/%d', $ticket ); |
|
WP_CLI::line( "Fetching HTML from Trac ticket $ticket" ); |
|
$html = file_get_contents( $trac_ticket_url ); |
|
|
|
$doc = new DOMDocument(); |
|
// @codingStandardsIgnoreStart |
|
@$doc->loadHTML( $html ); |
|
// @codingStandardsIgnoreEnd |
|
$xpath = new DOMXPath( $doc ); |
|
$dl = $xpath->query( '//dl[ @class = "attachments" ]' )->item( 0 ); |
|
|
|
if ( empty( $dl ) ) { |
|
WP_CLI::error( 'No attachments found.' ); |
|
} |
|
|
|
$attachments = array(); |
|
|
|
/** @var DOMElement $dl */ |
|
foreach ( $dl->getElementsByTagName( 'dt' ) as $dt ) { |
|
/** @var DOMElement $dt */ |
|
$nice_link = $dt->getElementsByTagName( 'a' )->item( 0 ); |
|
$patch_file_name = $nice_link->textContent; |
|
|
|
if ( ! preg_match( '/\.(diff|patch)$/', $patch_file_name ) ) { |
|
continue; |
|
} |
|
|
|
$patch_nice_url = 'https://core.trac.wordpress.org' . $nice_link->getAttribute( 'href' ); |
|
$patch_raw_url = 'https://core.trac.wordpress.org' . $dt->getElementsByTagName( 'a' )->item( 1 )->getAttribute( 'href' ); |
|
$author_user_name = $dt->getElementsByTagName( 'em' )->item( 0 )->textContent; |
|
$author_full_name = $this->get_display_name( $author_user_name ); |
|
$datetime = new DateTime( $dt->getElementsByTagName( 'a' )->item( 2 )->textContent, new DateTimeZone( 'UTC' ) ); |
|
|
|
$dd = $xpath->query( 'following-sibling::*[1][self::dd]', $dt )->item( 0 ); |
|
$description = null; |
|
if ( $dd ) { |
|
$description = trim( $dd->textContent ); |
|
} |
|
$author_email = sprintf( '%s@git.wordpress.org', $author_user_name ); |
|
|
|
$attachments[] = compact( |
|
'patch_file_name', |
|
'patch_nice_url', |
|
'patch_raw_url', |
|
'author_user_name', |
|
'author_full_name', |
|
'author_email', |
|
'datetime', |
|
'description' |
|
); |
|
} |
|
|
|
if ( 0 === count( $attachments ) ) { |
|
WP_CLI::warning( 'No patches on ticket' ); |
|
return; |
|
} |
|
|
|
$commit = trim( shell_exec( sprintf( 'git --no-pager log -1 --format="%%H %%P" --grep %s', escapeshellarg( $attachments[0]['patch_nice_url'] ) ) ) ); |
|
if ( ! empty( $commit ) ) { |
|
$hashes = explode( ' ', $commit ); |
|
$base_treeish = $hashes[1]; // The parent of the first patch that was applied. |
|
WP_CLI::log( 'Initial base commit for branch:' ); |
|
system( sprintf( 'git --no-pager log -1 %s', escapeshellarg( $base_treeish ) ) ); |
|
WP_CLI::line(); |
|
|
|
$last_merge_commit_hash = trim( shell_exec( sprintf( 'git --no-pager log -1 --reverse --merges --format="%%H" %s...HEAD', escapeshellarg( $base_treeish ) ) ) ); |
|
if ( $last_merge_commit_hash ) { |
|
WP_CLI::log( 'Fast-forwarding base commit to last merge:' ); |
|
system( sprintf( 'git log -1 %s', escapeshellarg( $last_merge_commit_hash ) ) ); |
|
WP_CLI::line(); |
|
$base_treeish = $last_merge_commit_hash; |
|
} |
|
} else { |
|
$base_treeish = trim( shell_exec( 'git show-ref --heads -s master' ) ); |
|
} |
|
|
|
$patches_applied = array(); |
|
$patches_needed = array(); |
|
|
|
foreach ( $attachments as $attachment ) { |
|
$commit_hash = trim( shell_exec( sprintf( 'git --no-pager log -1 --format="%%H" --fixed-strings --grep %s HEAD', escapeshellarg( $attachment['patch_nice_url'] ) ) ) ); |
|
if ( empty( $commit_hash ) ) { |
|
$commit_hash = trim( shell_exec( sprintf( 'git --no-pager log -1 --format="%%H" --fixed-strings --grep %s HEAD', escapeshellarg( $attachment['patch_raw_url'] ) ) ) ); |
|
} |
|
if ( ! empty( $commit_hash ) ) { |
|
WP_CLI::log( "Skipping previously-applied patch $attachment[patch_file_name] by $attachment[author_user_name], see commit $commit_hash." ); |
|
$patches_applied[] = $attachment; |
|
foreach ( $patches_needed as $dropped_attachment ) { |
|
WP_CLI::log( "Dropping apparently already-applied patch $dropped_attachment[patch_file_name] by $dropped_attachment[author_user_name]." ); |
|
} |
|
$patches_needed = array(); |
|
} else { |
|
$patches_needed[] = $attachment; |
|
} |
|
} |
|
|
|
foreach ( $patches_needed as $patch ) { |
|
$cache_file = '/tmp/wp-trac-patch-cache.' . md5( $patch['patch_raw_url'] ) . '-' . $patch['patch_file_name']; |
|
if ( file_exists( $cache_file ) ) { |
|
$contents = file_get_contents( $cache_file ); |
|
WP_CLI::log( 'Cache file: ' . $cache_file ); |
|
} else { |
|
WP_CLI::log( 'Fetching: ' . $patch['patch_raw_url'] ); |
|
$contents = file_get_contents( $patch['patch_raw_url'] ); |
|
file_put_contents( $cache_file, $contents ); |
|
} |
|
if ( empty( $contents ) ) { |
|
WP_CLI::error( 'HTTP error, empty patch: ' . $patch['patch_raw_url'] ); |
|
} |
|
|
|
$changed_files = array(); |
|
if ( ! preg_match_all( '#^(?:---|\+\+\+) (\S+)#m', $contents, $matches ) ) { |
|
WP_CLI::error( "Malformed patch: $cache_file" ); |
|
} |
|
foreach ( array_unique( $matches[1] ) as $path ) { |
|
if ( preg_match( '#^build/#', $path ) ) { |
|
continue; |
|
} |
|
if ( ! $this->is_develop_repo_rooted( $path ) ) { |
|
$path = 'src/' . $path; |
|
} |
|
$changed_files[] = $path; |
|
} |
|
|
|
file_put_contents( '.git/COMMIT_EDITMSG', $patch['patch_nice_url'] ); |
|
|
|
system( sprintf( 'git checkout %s -- .', escapeshellarg( $base_treeish ) ) ); |
|
system( sprintf( 'grunt patch:%s', escapeshellarg( $cache_file ), $return_var ) ); |
|
if ( 0 !== $return_var ) { |
|
WP_CLI::error( 'Failed to apply patch. Resolve conflicts and `git commit -F .git/COMMIT_EDITMSG`, or `git merge master` and try again.' ); |
|
} |
|
|
|
system( 'git add -A ' . join( ' ', array_map( 'escapeshellarg', $changed_files ) ) ); |
|
system( 'git diff-index --quiet HEAD', $return_var ); |
|
if ( 1 === $return_var ) { |
|
system( |
|
sprintf( 'git commit --author=%s -F .git/COMMIT_EDITMSG', |
|
escapeshellarg( sprintf( '%s <%s>', $patch['author_full_name'], $patch['author_email'] ) ), |
|
escapeshellarg( $patch['patch_nice_url'] ) |
|
), |
|
$return_var |
|
); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Prepare commit message, sorting props list by number of contributions made by each patch. |
|
*/ |
|
function commit_message() { |
|
// @todo |
|
} |
|
|
|
/** |
|
* @param string $username |
|
*/ |
|
protected function get_display_name( $username ) { |
|
|
|
$cache_file = '/tmp/wp-trac-user-display-names.json'; |
|
if ( is_readable( $cache_file ) ) { |
|
$display_names = json_decode( file_get_contents( $cache_file ), true ); |
|
} else { |
|
$display_names = array(); |
|
} |
|
|
|
if ( array_key_exists( $username, $display_names ) ) { |
|
return $display_names[ $username ]; |
|
} |
|
|
|
$html = file_get_contents( "https://profiles.wordpress.org/$username" ); |
|
WP_CLI::log( "Fetching display name for $username" ); |
|
$doc = new DOMDocument(); |
|
// @codingStandardsIgnoreStart |
|
@$doc->loadHTML( $html ); |
|
// @codingStandardsIgnoreEnd |
|
$xpath = new DOMXPath( $doc ); |
|
|
|
$h2 = $xpath->query( '//h2[ @class = "fn" ]' )->item( 0 ); |
|
if ( $h2 ) { |
|
$display_names[ $username ] = $h2->textContent; |
|
} else { |
|
$display_names[ $username ] = $username; |
|
} |
|
|
|
file_put_contents( $cache_file, json_encode( $display_names ) ); |
|
return $display_names[ $username ]; |
|
} |
|
|
|
protected function is_develop_repo_rooted( $path ) { |
|
$node = preg_replace( '#/.*#', '', $path ); |
|
|
|
$root_nodes = array( |
|
'.editorconfig', |
|
'.gitignore', |
|
'.jshintrc', |
|
'.travis.yml', |
|
'Gruntfile.js', |
|
'package.json', |
|
'phpunit.xml.dist', |
|
'src', |
|
'tests', |
|
'tools', |
|
'wp-cli.yml', |
|
'wp-config-sample.php', |
|
'wp-tests-config-sample.php', |
|
); |
|
|
|
return in_array( $node, $root_nodes ); |
|
} |
|
|
|
} |