PressLabs/gitium

View on GitHub
gitium/inc/class-git-wrapper.php

Summary

Maintainability
D
3 days
Test Coverage
<?php
/*  Copyright 2014-2016 Presslabs SRL <ping@presslabs.com>

    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License, version 2, as
    published by the Free Software Foundation.

    This program 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 this program; if not, write to the Free Software
    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
*/

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 );