laipower/wp-content/plugins/gitium/inc/class-git-wrapper.php

681 lines
18 KiB
PHP

<?php
/**
* Gitium provides automatic git version control and deployment for
* your plugins and themes integrated into wp-admin.
*
* Copyright (C) 2014-2025 PRESSINFRA SRL <ping@presslabs.com>
*
* Gitium is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Gitium is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Gitium. If not, see <http://www.gnu.org/licenses/>.
*
* @package Gitium
*/
if (!defined('GITIGNORE'))
define('GITIGNORE', <<<EOF
*.log
*.swp
*.back
*.bak
*.sql
*.sql.gz
~*
.htaccess
.maintenance
wp-config.php
sitemap.xml
sitemap.xml.gz
wp-content/uploads/
wp-content/blogs.dir/
wp-content/upgrade/
wp-content/backup-db/
wp-content/cache/
wp-content/backups/
wp-content/advanced-cache.php
wp-content/object-cache.php
wp-content/wp-cache-config.php
wp-content/db.php
wp-admin/
wp-includes/
/index.php
/license.txt
/readme.html
# de_DE
/liesmich.html
# it_IT
/LEGGIMI.txt
/licenza.html
# da_DK
/licens.html
# es_ES, es_PE
/licencia.txt
# hu_HU
/licenc.txt
/olvasdel.html
# sk_SK
/licencia-sk_SK.txt
# sv_SE
/licens-sv_SE.txt
/wp-activate.php
/wp-blog-header.php
/wp-comments-post.php
/wp-config-sample.php
/wp-cron.php
/wp-links-opml.php
/wp-load.php
/wp-login.php
/wp-mail.php
/wp-settings.php
/wp-signup.php
/wp-trackback.php
/xmlrpc.php
EOF
);
class Git_Wrapper {
private $last_error = '';
private $gitignore = GITIGNORE;
private $repo_dir = '';
private $private_key = '';
function __construct( $repo_dir ) {
$this->repo_dir = $repo_dir;
}
function _rrmdir( $dir ) {
if ( empty( $dir ) || ! is_dir( $dir ) ) {
return false;
}
$files = array_diff( scandir( $dir ), array( '.', '..' ) );
foreach ( $files as $file ) {
$filepath = realpath("$dir/$file");
( is_dir( $filepath ) ) ? $this->_rrmdir( $filepath ) : unlink( $filepath );
}
return rmdir( $dir );
}
function _log(...$args) {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) { return; }
$output = '';
if (isset($args) && $args) foreach ( $args as $arg ) {
$output .= var_export($arg, true).'/n/n';
}
if ($output) error_log($output);
}
function _git_temp_key_file() {
$key_file = tempnam( sys_get_temp_dir(), 'ssh-git' );
return $key_file;
}
function set_key( $private_key ) {
$this->private_key = $private_key;
}
private function get_env() {
$env = array(
'HOME' => getenv( 'HOME' ),
);
$key_file = null;
if ( defined( 'GIT_SSH' ) && GIT_SSH ) {
$env['GIT_SSH'] = GIT_SSH;
} else {
$env['GIT_SSH'] = dirname( __FILE__ ) . '/ssh-git';
}
if ( defined( 'GIT_KEY_FILE' ) && GIT_KEY_FILE ) {
$env['GIT_KEY_FILE'] = GIT_KEY_FILE;
} elseif ( $this->private_key ) {
$key_file = $this->_git_temp_key_file();
chmod( $key_file, 0600 );
file_put_contents( $key_file, $this->private_key );
$env['GIT_KEY_FILE'] = $key_file;
}
return $env;
}
protected function _call(...$args) {
$args = join( ' ', array_map( 'escapeshellarg', $args ) );
$return = -1;
$response = array();
$env = $this->get_env();
$git_bin_path = apply_filters( 'gitium_git_bin_path', '' );
$cmd = "{$git_bin_path}git $args 2>&1";
$proc = proc_open(
$cmd,
array(
0 => array( 'pipe', 'r' ), // stdin
1 => array( 'pipe', 'w' ), // stdout
),
$pipes,
$this->repo_dir,
$env
);
if ( is_resource( $proc ) ) {
fclose( $pipes[0] );
while ( $line = fgets( $pipes[1] ) ) {
$response[] = rtrim( $line, "\n\r" );
}
$return = (int)proc_close( $proc );
}
$this->_log( "$return $cmd", join( "\n", $response ) );
if ( ! defined( 'GIT_KEY_FILE' ) && isset( $env['GIT_KEY_FILE'] ) ) {
unlink( $env['GIT_KEY_FILE'] );
}
if ( 0 != $return ) {
$this->last_error = join( "\n", $response );
} else {
$this->last_error = null;
}
return array( $return, $response );
}
function get_last_error() {
return $this->last_error;
}
function can_exec_git() {
list( $return, ) = $this->_call( 'version' );
return ( 0 == $return );
}
function is_status_working() {
list( $return, ) = $this->_call( 'status', '-s' );
return ( 0 == $return );
}
function get_version() {
list( $return, $version ) = $this->_call( 'version' );
if ( 0 != $return ) { return ''; }
if ( ! empty( $version[0] ) ) {
return substr( $version[0], 12 );
}
return '';
}
// git rev-list @{u}..
function get_ahead_commits() {
list( , $commits ) = $this->_call( 'rev-list', '@{u}..' );
return $commits;
}
// git rev-list ..@{u}
function get_behind_commits() {
list( , $commits ) = $this->_call( 'rev-list', '..@{u}' );
return $commits;
}
function init() {
file_put_contents( "$this->repo_dir/.gitignore", $this->gitignore );
list( $return, ) = $this->_call( 'init' );
$this->_call( 'config', 'user.email', 'gitium@presslabs.com' );
$this->_call( 'config', 'user.name', 'Gitium' );
$this->_call( 'config', 'push.default', 'matching' );
return ( 0 == $return );
}
function is_dot_git_dir( $dir ) {
$realpath = realpath( $dir );
$git_config = realpath( $realpath . '/config' );
$git_index = realpath( $realpath . '/index' );
if ( ! empty( $realpath ) && is_dir( $realpath ) && file_exists( $git_config ) && file_exists( $git_index ) ) {
return True;
}
return False;
}
function cleanup() {
$dot_git_dir = realpath( $this->repo_dir . '/.git' );
if ( $this->is_dot_git_dir( $dot_git_dir ) && $this->_rrmdir( $dot_git_dir ) ) {
if ( WP_DEBUG ) {
error_log( "Gitium cleanup successfull. Removed '$dot_git_dir'." );
}
return True;
}
if ( WP_DEBUG ) {
error_log( "Gitium cleanup failed. '$dot_git_dir' is not a .git dir." );
}
return False;
}
function add_remote_url( $url ) {
list( $return, ) = $this->_call( 'remote', 'add', 'origin', $url );
return ( 0 == $return );
}
function get_remote_url() {
list( , $response ) = $this->_call( 'config', '--get', 'remote.origin.url' );
if ( isset( $response[0] ) ) {
return $response[0];
}
return '';
}
function remove_remote() {
list( $return, ) = $this->_call( 'remote', 'rm', 'origin');
return ( 0 == $return );
}
function get_remote_tracking_branch() {
list( $return, $response ) = $this->_call( 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}' );
if ( 0 == $return ) {
return $response[0];
}
return false;
}
function get_local_branch() {
list( $return, $response ) = $this->_call( 'rev-parse', '--abbrev-ref', 'HEAD' );
if ( 0 == $return ) {
return $response[0];
}
return false;
}
function fetch_ref() {
list( $return, ) = $this->_call( 'fetch', 'origin' );
return ( 0 == $return );
}
protected function _resolve_merge_conflicts( $message ) {
list( , $changes ) = $this->status( true );
$this->_log( $changes );
foreach ( $changes as $path => $change ) {
if ( in_array( $change, array( 'UD', 'DD' ) ) ) {
$this->_call( 'rm', $path );
$message .= "\n\tConflict: $path [removed]";
} elseif ( 'DU' == $change ) {
$this->_call( 'add', $path );
$message .= "\n\tConflict: $path [added]";
} elseif ( in_array( $change, array( 'AA', 'UU', 'AU', 'UA' ) ) ) {
$this->_call( 'checkout', '--theirs', $path );
$this->_call( 'add', '--all', $path );
$message .= "\n\tConflict: $path [local version]";
}
}
$this->commit( $message );
}
function get_commit_message( $commit ) {
list( $return, $response ) = $this->_call( 'log', '--format=%B', '-n', '1', $commit );
return ( $return !== 0 ? false : join( "\n", $response ) );
}
private function strpos_haystack_array( $haystack, $needle, $offset=0 ) {
if ( ! is_array( $haystack ) ) { $haystack = array( $haystack ); }
foreach ( $haystack as $query ) {
if ( strpos( $query, $needle, $offset) !== false ) { return true; }
}
return false;
}
private function cherry_pick( $commits ) {
foreach ( $commits as $commit ) {
if ( empty( $commit ) ) { return false; }
list( $return, $response ) = $this->_call( 'cherry-pick', $commit );
// abort the cherry-pick if the changes are already pushed
if ( false !== $this->strpos_haystack_array( $response, 'previous cherry-pick is now empty' ) ) {
$this->_call( 'cherry-pick', '--abort' );
continue;
}
if ( $return != 0 ) {
$this->_resolve_merge_conflicts( $this->get_commit_message( $commit ) );
}
}
}
function merge_with_accept_mine(...$commits) {
do_action( 'gitium_before_merge_with_accept_mine' );
if ( 1 == count($commits) && is_array( $commits[0] ) ) {
$commits = $commits[0];
}
// get ahead commits
$ahead_commits = $this->get_ahead_commits();
// combine all commits with the ahead commits
$commits = array_unique( array_merge( array_reverse( $commits ), $ahead_commits ) );
$commits = array_reverse( $commits );
// get the remote branch
$remote_branch = $this->get_remote_tracking_branch();
// get the local branch
$local_branch = $this->get_local_branch();
// rename the local branch to 'merge_local'
$this->_call( 'branch', '-m', 'merge_local' );
// local branch set up to track remote branch
$this->_call( 'branch', $local_branch, $remote_branch );
// checkout to the $local_branch
list( $return, ) = $this->_call( 'checkout', $local_branch );
if ( $return != 0 ) {
$this->_call( 'branch', '-M', $local_branch );
return false;
}
// don't cherry pick if there are no commits
if ( count( $commits ) > 0 ) {
$this->cherry_pick( $commits );
}
if ( $this->successfully_merged() ) { // git status without states: AA, DD, UA, AU ...
// delete the 'merge_local' branch
$this->_call( 'branch', '-D', 'merge_local' );
return true;
} else {
$this->_call( 'cherry-pick', '--abort' );
$this->_call( 'checkout', '-b', 'merge_local' );
$this->_call( 'branch', '-M', $local_branch );
return false;
}
}
function successfully_merged() {
list( , $response ) = $this->status( true );
$changes = array_values( $response );
return ( 0 == count( array_intersect( $changes, array( 'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU' ) ) ) );
}
function merge_initial_commit( $commit, $branch ) {
list( $return, ) = $this->_call( 'branch', '-m', 'initial' );
if ( 0 != $return ) {
return false;
}
list( $return, ) = $this->_call( 'checkout', $branch );
if ( 0 != $return ) {
return false;
}
list( $return, ) = $this->_call(
'cherry-pick', '--strategy', 'recursive', '--strategy-option', 'theirs', $commit
);
if ( $return != 0 ) {
$this->_resolve_merge_conflicts( $this->get_commit_message( $commit ) );
if ( ! $this->successfully_merged() ) {
$this->_call( 'cherry-pick', '--abort' );
$this->_call( 'checkout', 'initial' );
return false;
}
}
$this->_call( 'branch', '-D', 'initial' );
return true;
}
function get_remote_branches() {
list( , $response ) = $this->_call( 'branch', '-r' );
$response = array_map( 'trim', $response );
$response = array_map( function( $b ) { return str_replace( "origin/", "", $b ); }, $response );
return $response;
}
function add(...$args) {
if ( 1 == count($args) && is_array( $args[0] ) ) {
$args = $args[0];
}
$params = array_merge( array( 'add', '-n', '--all' ), $args );
list ( , $response ) = call_user_func_array( array( $this, '_call' ), $params );
$count = count( $response );
$params = array_merge( array( 'add', '--all' ), $args );
list ( , $response ) = call_user_func_array( array( $this, '_call' ), $params );
return $count;
}
function commit( $message, $author_name = '', $author_email = '' ) {
$author = '';
if ( $author_email ) {
if ( empty( $author_name ) ) {
$author_name = $author_email;
}
$author = "$author_name <$author_email>";
}
if ( ! empty( $author ) ) {
list( $return, $response ) = $this->_call( 'commit', '-m', $message, '--author', $author );
} else {
list( $return, $response ) = $this->_call( 'commit', '-m', $message );
}
if ( $return !== 0 ) { return false; }
list( $return, $response ) = $this->_call( 'rev-parse', 'HEAD' );
return ( $return === 0 ) ? $response[0] : false;
}
function push( $branch = '' ) {
if ( ! empty( $branch ) ) {
list( $return, ) = $this->_call( 'push', '--porcelain', '-u', 'origin', $branch );
} else {
list( $return, ) = $this->_call( 'push', '--porcelain', '-u', 'origin', 'HEAD' );
}
return ( $return == 0 );
}
/*
* Get uncommited changes with status porcelain
* git status --porcelain
* It returns an array like this:
array(
file => deleted|modified
...
)
*/
function get_local_changes() {
list( $return, $response ) = $this->_call( 'status', '--porcelain' );
if ( 0 !== $return ) {
return array();
}
$new_response = array();
if ( ! empty( $response ) ) {
foreach ( $response as $line ) :
$work_tree_status = substr( $line, 1, 1 );
$path = substr( $line, 3 );
if ( ( '"' == $path[0] ) && ('"' == $path[strlen( $path ) - 1] ) ) {
// git status --porcelain will put quotes around paths with whitespaces
// we don't want the quotes, let's get rid of them
$path = substr( $path, 1, strlen( $path ) - 2 );
}
if ( 'D' == $work_tree_status ) {
$action = 'deleted';
} else {
$action = 'modified';
}
$new_response[ $path ] = $action;
endforeach;
}
return $new_response;
}
function get_uncommited_changes() {
list( , $changes ) = $this->status();
return $changes;
}
function local_status() {
list( $return, $response ) = $this->_call( 'status', '-s', '-b', '-u' );
if ( 0 !== $return ) {
return array( '', array() );
}
$new_response = array();
if ( ! empty( $response ) ) {
$branch_status = array_shift( $response );
foreach ( $response as $idx => $line ) :
unset( $index_status, $work_tree_status, $path, $new_path, $old_path );
if ( empty( $line ) ) { continue; } // ignore empty lines like the last item
if ( '#' == $line[0] ) { continue; } // ignore branch status
$index_status = substr( $line, 0, 1 );
$work_tree_status = substr( $line, 1, 1 );
$path = substr( $line, 3 );
$old_path = '';
$new_path = explode( '->', $path );
if ( ( 'R' === $index_status ) && ( ! empty( $new_path[1] ) ) ) {
$old_path = trim( $new_path[0] );
$path = trim( $new_path[1] );
}
$new_response[ $path ] = trim( $index_status . $work_tree_status . ' ' . $old_path );
endforeach;
}
return array( $branch_status, $new_response );
}
function status( $local_only = false ) {
list( $branch_status, $new_response ) = $this->local_status();
if ( $local_only ) { return array( $branch_status, $new_response ); }
$behind_count = 0;
$ahead_count = 0;
if ( preg_match( '/## ([^.]+)\.+([^ ]+)/', $branch_status, $matches ) ) {
$local_branch = $matches[1];
$remote_branch = $matches[2];
list( , $response ) = $this->_call( 'rev-list', "$local_branch..$remote_branch", '--count' );
$behind_count = (int)$response[0];
list( , $response ) = $this->_call( 'rev-list', "$remote_branch..$local_branch", '--count' );
$ahead_count = (int)$response[0];
}
if ( $behind_count ) {
list( , $response ) = $this->_call( 'diff', '-z', '--name-status', "$local_branch~$ahead_count", $remote_branch );
$response = explode( chr( 0 ), $response[0] );
array_pop( $response );
for ( $idx = 0 ; $idx < count( $response ) / 2 ; $idx++ ) {
$file = $response[ $idx * 2 + 1 ];
$change = $response[ $idx * 2 ];
if ( ! isset( $new_response[ $file ] ) ) {
$new_response[ $file ] = "r$change";
}
}
}
return array( $branch_status, $new_response );
}
/*
* Checks if repo has uncommited changes
* git status --porcelain
*/
function is_dirty() {
$changes = $this->get_uncommited_changes();
return ! empty( $changes );
}
/**
* Return the last n commits
*/
function get_last_commits( $n = 20 ) {
list( $return, $message ) = $this->_call( 'log', '-n', $n, '--pretty=format:%s' );
if ( 0 !== $return ) { return false; }
list( $return, $response ) = $this->_call( 'log', '-n', $n, '--pretty=format:%h|%an|%ae|%ad|%cn|%ce|%cd' );
if ( 0 !== $return ) { return false; }
foreach ( $response as $index => $value ) {
$commit_info = explode( '|', $value );
$commits[ $commit_info[0] ] = array(
'subject' => $message[ $index ],
'author_name' => $commit_info[1],
'author_email' => $commit_info[2],
'author_date' => $commit_info[3],
);
if ( $commit_info[1] != $commit_info[4] && $commit_info[2] != $commit_info[5] ) {
$commits[ $commit_info[0] ]['committer_name'] = $commit_info[4];
$commits[ $commit_info[0] ]['committer_email'] = $commit_info[5];
$commits[ $commit_info[0] ]['committer_date'] = $commit_info[6];
}
}
return $commits;
}
public function set_gitignore( $content ) {
file_put_contents( $this->repo_dir . '/.gitignore', $content );
return true;
}
public function get_gitignore() {
return file_get_contents( $this->repo_dir . '/.gitignore' );
}
/**
* Remove files in .gitignore from version control
*/
function rm_cached( $path ) {
list( $return, ) = $this->_call( 'rm', '--cached', $path );
return ( $return == 0 );
}
function remove_wp_content_from_version_control() {
$process = proc_open(
'rm -rf ' . ABSPATH . '/wp-content/.git',
array(
0 => array( 'pipe', 'r' ), // stdin
1 => array( 'pipe', 'w' ), // stdout
),
$pipes
);
if ( is_resource( $process ) ) {
fclose( $pipes[0] );
proc_close( $process );
return true;
}
return false;
}
}
if ( ! defined( 'GIT_DIR' ) ) {
define( 'GIT_DIR', dirname( WP_CONTENT_DIR ) );
}
# global is needed here for wp-cli as it includes/exec files inside a function scope
# this forces the context to really be global :\.
global $git;
$git = new Git_Wrapper( GIT_DIR );