ldc-sbv/pole-sbv-content/plugins/revisr/classes/class-revisr-git.php
2017-09-06 14:18:47 +02:00

802 lines
20 KiB
PHP
Executable file

<?php
/**
* class-revisr-git.php
*
* Processes interactions with Git.
*
* @package Revisr
* @license GPLv3
* @link https://revisr.io
* @copyright Expanded Fronts, LLC
*/
// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) exit;
// The main Git class.
class Revisr_Git {
/**
* Stores the current branch in Git.
* @var string
*/
public $branch;
/**
* Stores the current remote used by Git.
* @var string
*/
public $remote;
/**
* Stores the ID of the current commit.
* @var string
*/
public $current_commit;
/**
* Stores the current directory during runtime.
* @var string
*/
public $current_dir;
/**
* Stores the path to the .git directory.
* @var string
*/
public $git_dir;
/**
* Stores the path to the working directory.
* @var string
*/
public $work_tree;
/**
* Stores the path to Git.
* @var string
*/
public $git_path;
/**
* Stores the state of the repository
* @var boolean
*/
public $is_repo;
/**
* Initiates the class and it's properties.
* @access public
*/
public function __construct() {
// Necessary for execution of Revisr.
$this->is_repo = true;
$this->current_dir = getcwd();
$this->git_path = $this->get_git_path();
$this->git_dir = $this->get_git_dir();
$this->work_tree = $this->get_work_tree();
// Make sure the provided Git directory is valid.
$this->check_work_tree();
// Load up information about the current repository.
if ( $this->is_repo ) {
$this->branch = $this->current_branch();
$this->remote = $this->current_remote();
$this->current_commit = $this->current_commit();
}
}
/**
* Runs a Git command and fires the given callback.
* @access public
* @param string $command The command to use.
* @param array $args Arguements provided by user.
* @param string $callback The callback to use.
* @param string|array $info Additional info to pass to the callback
*/
public function run( $command, $args, $callback = '', $info = '' ) {
// Setup the command for safe usage.
$safe_path = Revisr_Admin::escapeshellarg( $this->git_path );
$safe_cmd = Revisr_Admin::escapeshellarg( $command );
$safe_args = join( ' ', array_map( array( 'Revisr_Admin', 'escapeshellarg' ), $args ) );
// Allow for customizing the git work-tree and git-dir paths.
$git_dir = Revisr_Admin::escapeshellarg( "--git-dir=$this->git_dir" );
$work_tree = Revisr_Admin::escapeshellarg( "--work-tree=$this->work_tree" );
// Run the command.
chdir( $this->work_tree );
exec( "$safe_path $git_dir $work_tree $safe_cmd $safe_args 2>&1", $output, $return_code );
chdir( $this->current_dir );
// Process the response.
$response = new Revisr_Git_Callback();
$success_callback = 'success_' . $callback;
$failure_callback = 'null_' . $callback;
// Return the callback.
if ( 0 !== $return_code ) {
return $response->$failure_callback( $output, $info );
} else {
return $response->$success_callback( $output, $info );
}
}
/**
* Checks if the provided path is a Git repository.
* @access public
* @param string $dir The directory to check (optional).
* @return boolean
*/
public function check_work_tree( $dir = '' ) {
// If no dir provided, use constant.
if ( '' === $dir ) {
$dir = $this->get_work_tree();
}
// Definitely bail if not a directory.
if ( ! is_dir( $dir ) ) {
return false;
}
$this->work_tree = $dir;
// Check with Git binary if repo is detected.
$git_toplevel = $this->run( 'rev-parse', array( '--show-toplevel' ) );
// If not, set the is_repo flag to false.
if ( ! $git_toplevel ) {
$this->is_repo = false;
return false;
}
return true;
}
/**
* Returns the path to the top-level Git directory.
* @access public
* @return string The path to the top-level Git directory.
*/
public function get_git_dir() {
if ( defined( 'REVISR_GIT_DIR' ) && is_dir( REVISR_GIT_DIR ) ) {
// Force removal of trailing slash upfront.
$git_dir = rtrim( REVISR_GIT_DIR, DIRECTORY_SEPARATOR );
if ( is_dir( REVISR_GIT_DIR . DIRECTORY_SEPARATOR . '.git' ) ) {
// Workaround for backwards compatibility.
if ( ! defined( 'REVISR_WORK_TREE' ) ) {
// Define the REVISR_WORK_TREE constant to match the old REVISR_GIT_DIR constant.
define( 'REVISR_WORK_TREE', REVISR_GIT_DIR );
$line = "define('REVISR_WORK_TREE', '" . REVISR_GIT_DIR . "');";
Revisr_Admin::replace_config_line( 'define *\( *\'REVISR_WORK_TREE\'', $line );
// Update the old REVISR_GIT_DIR constant.
$git_dir = REVISR_GIT_DIR . DIRECTORY_SEPARATOR . '.git';
$line = "define('REVISR_GIT_DIR', '" . REVISR_GIT_DIR . DIRECTORY_SEPARATOR . ".git');";
Revisr_Admin::replace_config_line( 'define *\( *\'REVISR_GIT_DIR\'', $line );
}
} else {
$git_dir = REVISR_GIT_DIR;
}
} else {
// Best guess.
$git_dir = $this->get_work_tree() . DIRECTORY_SEPARATOR . '.git';
}
return $git_dir;
}
/**
* Returns the current path to Git.
* @access public
* @return string The path to the installation of Git.
*/
public function get_git_path() {
if ( defined( 'REVISR_GIT_PATH' ) && file_exists( REVISR_GIT_PATH ) ) {
return REVISR_GIT_PATH;
} else {
// This is surprisingly still the best option
// given the huge amount of possible install paths,
// and ~90% of the time this will work anyway.
return 'git';
}
}
/**
* Returns the path to the Git work-tree in exec() friendly format.
* @access public
* @return string
*/
public function get_work_tree() {
if ( defined( 'REVISR_WORK_TREE' ) && is_dir( REVISR_WORK_TREE ) ) {
$work_tree = REVISR_WORK_TREE;
} else {
$work_tree = ABSPATH;
}
// Remove trailing slash.
$work_tree = rtrim( $work_tree, DIRECTORY_SEPARATOR );
return $work_tree;
}
/**
* Pushes changes if "Automatically push new commits" is enabled.
* @access public
*/
public function auto_push() {
// Allow for preventing auto-push on a per-commit basis.
if ( isset( $_REQUEST['autopush_enabled'] ) && ! isset( $_REQUEST['auto_push'] ) ) {
return;
}
// Push the changes if needed.
if ( $this->get_config( 'revisr', 'auto-push' ) === 'true' || isset( $_REQUEST['auto_push'] ) ) {
$this->push();
}
}
/**
* Checks out an existing branch.
* @access public
* @param string $branch The branch to checkout.
*/
public function checkout( $branch, $new_branch = false ) {
if ( $new_branch ) {
$args = array( '-b', $branch, '-q' );
} else {
$args = array( $branch, '-q' );
}
$this->run( 'checkout', $args, __FUNCTION__ );
}
/**
* Commits any staged files to the local repository.
* @access public
* @param string $message The message to use with the commit.
* @param string $callback The callback to run.
*/
public function commit( $message, $callback = '' ) {
if ( is_user_logged_in() ) {
$current_user = wp_get_current_user();
$author = "$current_user->user_login <$current_user->user_email>";
$commit = $this->run( 'commit', array( '-m', $message, '--author', $author ), $callback );
} else {
$commit = $this->run( 'commit', array( '-m', $message ), $callback );
}
return $commit;
}
/**
* Sets a value in the .git/config.
* @access public
* @param string $section The section to store/update the value for.
* @param string $key The key for the value which is being updated.
* @param string $value The value to set.
* @return boolean
*/
public function set_config( $section, $key, $value ) {
if ( ! $this->run( 'config', array( "$section.$key", $value ) ) ) {
return false;
}
return true;
}
/**
* Gets a single value from the config.
* @access public
* @param string $section The section to check from.
* @param string $key The key who's value to grab.
* @return string|boolean
*/
public function get_config( $section, $key ) {
$result = $this->run( 'config', array( '--get', "$section.$key" ) );
if ( is_array( $result ) ) {
return $result[0];
} elseif ( is_string( $result ) ) {
return $result;
} else {
return false;
}
}
/**
* Returns the number of unpulled commits.
* @access public
*/
public function count_unpulled( $ajax_btn = true ) {
$this->fetch();
if ( true === $ajax_btn ) {
$this->run( 'log', array( $this->branch . '..' . $this->remote . '/' . $this->branch, '--pretty=oneline' ), 'count_ajax_btn' );
} else {
$unpulled = $this->run( 'log', array( $this->branch . '..' . $this->remote . '/' . $this->branch, '--pretty=oneline' ) );
return count( $unpulled );
}
}
/**
* Returns the number of unpushed commits.
* @access public
*/
public function count_unpushed( $ajax_btn = true ) {
if ( true === $ajax_btn ) {
$this->run( 'log', array( $this->branch, '--not', '--remotes', '--oneline' ), 'count_ajax_btn' );
} else {
$unpushed = $this->run( 'log', array( $this->branch, '--not', '--remotes', '--oneline' ) );
return count( $unpushed );
}
}
/**
* Returns the number of untracked/modified files.
* @access public
*/
public function count_untracked() {
$untracked = $this->run( 'status', array( '--short', '--untracked-files=all' ) );
return count( $untracked );
}
/**
* Creates a new branch.
* @access public
* @param string $branch The name of the branch to create.
*/
public function create_branch( $branch ) {
$new_branch = $this->run( 'branch', array( $branch ) );
return $new_branch;
}
/**
* Returns the current branch.
* @access public
*/
public function current_branch() {
$current_branch = $this->run( 'rev-parse', array( '--abbrev-ref', 'HEAD' ) );
if ( $current_branch != false && is_array( $current_branch ) ) {
return $current_branch[0];
}
}
/**
* Returns the hash of the current commit.
* @access public
*/
public function current_commit() {
$commit_hash = $this->run( 'rev-parse', array( '--short', 'HEAD' ) );
if ( is_array( $commit_hash ) ) {
return $commit_hash[0];
}
}
/**
* Returns the name of the current remote.
* @access public
* @return string
*/
public function current_remote() {
$remote = $this->get_config( 'revisr', 'current-remote' );
if ( ! $remote ) {
$remote = 'origin';
}
return $remote;
}
/**
* Deletes a local or remote branch.
* @access public
* @param string $branch The branch to delete.
* @param boolean $redirect Whether or not to redirect on completion.
* @param boolean $remote Whether $branch is a local or remote branch.
* @return mixed
*/
public function delete_branch( $branch, $redirect = true, $remote = false ) {
$callback = '';
if ( $redirect ) {
$callback = __FUNCTION__;
}
if ( $remote ) {
$deletion = $this->run( 'push', array( $this->remote, ":$branch" ), $callback, $branch );
} else {
$deletion = $this->run( 'branch', array( '-D', $branch ), $callback, $branch );
}
return $deletion;
}
/**
* Fetches changes without merging them.
* @access public
*/
public function fetch() {
$fetch = $this->run( 'fetch', array() );
return $fetch;
}
/**
* Returns available branches on the local or remote repository.
* @access public
* @param boolean $remote If set to true, will retrieve the remote branches.
*/
public function get_branches( $remote = false ) {
$params = array();
if ( true === $remote ) {
$params[] = '-r';
}
return $this->run( 'branch', $params );
}
/**
* Returns the date of last update for a branch/ref.
* @access public
* @return string
*/
public function get_branch_last_updated( $branch, $format = '' ) {
$last_updated = $this->run( 'log', array( $branch, '-1', '--format=%cd', '--date=relative' ) );
if ( $last_updated ) {
return $last_updated[0];
} else {
return __( 'Unknown', 'revisr' );
}
}
/**
* Returns the author of a provided commit.
* @access public
* @param string $commit_hash The hash of the commit to get the author of.
* @return string|boolean
*/
public function get_commit_author_by_hash( $commit_hash ) {
$author = $this->run( 'log', array( '-1', '--format=%an', $commit_hash ) );
if ( is_array( $author ) ) {
return $author[0];
}
return false;
}
/**
* Returns an array of details on the provided commit.
* @access public
* @param string $hash The SHA1 of the commit to check.
* @return array
*/
public static function get_commit_details( $hash ) {
// Build an associative array of details.
$commit_details = array(
'hash' => $hash,
'branch' => '',
'author' => '',
'subject' => __( 'Commit not found', 'revisr' ),
'time' => time(),
'files_changed' => 0,
'committed_files' => array(),
'has_db_backup' => false,
'tag' => '',
'status' => false,
);
// Try to get some basic data about the commit.
$commit = revisr()->git->run( 'show', array( '--pretty=format:%s|#|%an|#|%at', '--name-status', '--root', '-r', $hash ) );
if ( is_array( $commit ) ) {
$commit_meta = $commit[0];
$commit_meta = explode( '|#|', $commit_meta );
unset( $commit[0] );
$commit_details['subject'] = $commit_meta[0];
$commit_details['author'] = $commit_meta[1];
$commit_details['time'] = $commit_meta[2];
$commit = array_filter( $commit );
$backed_up_tables = preg_grep( '/revisr.*sql/', $commit );
if ( 0 !== count( $backed_up_tables ) ) {
$commit_details['has_db_backup'] = true;
}
$commit_details['files_changed'] = count( $commit );
$commit_details['committed_files'] = $commit;
$branches = revisr()->git->run( 'branch', array( '--contains', $hash ) );
$commit_details['branch'] = is_array( $branches ) ? implode( ', ', $branches ) : __( 'Unknown', 'revisr' );
$commit_details['status'] = __( 'Committed', 'revisr' );
}
// Return the array.
return $commit_details;
}
/**
* Returns the status of a file.
* @access public
* @param string $status The status code returned via 'git status --short'
*/
public static function get_status( $status ) {
if ( strpos( $status, 'M' ) !== false ){
$status = __( 'Modified', 'revisr' );
} elseif ( strpos( $status, 'D' ) !== false ){
$status = __( 'Deleted', 'revisr' );
} elseif ( strpos( $status, 'A' ) !== false ){
$status = __( 'Added', 'revisr' );
} elseif ( strpos( $status, 'R' ) !== false ){
$status = __( 'Renamed', 'revisr' );
} elseif ( strpos( $status, 'U' ) !== false ){
$status = __( 'Updated', 'revisr' );
} elseif ( strpos( $status, 'C' ) !== false ){
$status = __( 'Copied', 'revisr' );
} elseif ( strpos( $status, '??' ) !== false ){
$status = __( 'Untracked', 'revisr' );
} else {
$status = false;
}
return $status;
}
/**
* Checks if the repo has a remote.
* @access public
* @return boolean
*/
public function has_remote() {
if ( $this->get_config( 'remote', 'origin.url' ) ) {
return true;
}
return false;
}
/**
* Initializes a new repository.
* @access public
*/
public function init_repo() {
$init = $this->run( 'init', array( '.' ), __FUNCTION__ );
return $init;
}
/**
* Checks if the provided branch is an existing branch.
* @access public
* @param string $branch The name of the branch to check.
* @return boolean
*/
public function is_branch( $branch ) {
$branches = $this->get_branches();
if ( in_array( $branch, $branches ) || in_array( "* $branch", $branches ) || in_array( " $branch", $branches ) ) {
return true;
} else {
return false;
}
}
/**
* Merges a branch into the current branch.
* @access public
* @param string $branch The branch to merge into the current branch.
*/
public function merge( $branch ) {
$this->reset();
$merge = $this->run( 'merge', array( $branch, '--strategy-option', 'theirs' ), __FUNCTION__ );
return $merge;
}
/**
* Pulls changes from the remote repository.
* @access public
* @param array $commits The commits we're pulling (used in callback).
*/
public function pull( $commits = array() ) {
$this->reset();
$pull = $this->run( 'pull', array( '-Xtheirs', '--quiet', $this->remote, $this->branch ), __FUNCTION__, $commits );
return $pull;
}
/**
* Pushes changes to the remote repository.
* @access public
*/
public function push() {
$push = $this->run( 'push', array( $this->remote, 'HEAD', '--quiet' ), __FUNCTION__, $this->count_unpushed( false ) );
return $push;
}
/**
* Resets the working directory.
* @access public
* @param string $mode The mode to use for the reset (hard, soft, etc.).
* @param string $path The path to apply the reset to.
* @param boolean $clean Whether to remove any untracked files.
* @return boolean
*/
public function reset( $mode = '--hard', $path = 'HEAD', $clean = false ) {
if ( $this->run( 'reset', array( $mode, $path ) ) ) {
if ( true === $clean ) {
if ( ! $this->run( 'clean', array( '-f', '-d' ) ) ) {
return false;
}
}
return true;
}
return false;
}
/**
* Reverts the working directory to a specified commit.
* @access public
* @param string $commit The hash of the commit to revert to.
*/
public function revert( $commit ) {
$this->reset( '--hard', 'HEAD', true );
$this->reset( '--hard', $commit );
$this->reset( '--soft', 'HEAD@{1}' );
}
/**
* Stages the array of files passed through the New Commit screen.
* @access public
* @param array $staged_files An array of files to add/remove.
* @param boolean $stage_all If set to true, skip the loop and add -A.
* @return boolean
*/
public function stage_files( $staged_files, $stage_all = false ) {
// An empty array for errors.
$errors = array();
if ( true === $stage_all ) {
if ( $this->run( 'add', array( '-A' ) ) === false ) {
$errors[] = $staged_files;
}
} else {
foreach ( $staged_files as $result ) {
$file = substr( $result, 3 );
$status = self::get_status( substr( $result, 0, 2 ) );
if ( $status == __( 'Deleted', 'revisr' ) ) {
if ( $this->run( 'rm', array( $file ) ) === false ) {
$errors[] = $file;
}
} else {
if ( $this->run( 'add', array( $file ) ) === false ) {
$errors[] = $file;
}
}
}
}
if ( ! empty( $errors ) ) {
$msg = __( 'There was an error staging the files. Please check the settings and try again.', 'revisr' );
Revisr_Admin::alert( $msg, true );
Revisr_Admin::log( __( 'Error staging files.', 'revisr' ), 'error' );
return false;
}
return true;
}
/**
* Returns the current status.
* @access public
* @param array $args Defaults to "--short".
*/
public function status( $args = array( '--short', '--untracked-files=all' ) ) {
$status = $this->run( 'status', $args );
return $status;
}
/**
* Adds a tag to the repository, or returns a list of tags if no parameters are passed.
* @access public
* @param string $tag The tag to add.
*/
public function tag( $tag = '' ) {
$tag = $this->run( 'tag', array( $tag ) );
return $tag;
}
/**
* Updates the .gitignore file, and removes files from version control,
* making sure to keep the physical copies of the files on the server.
* @access public
*/
public function update_gitignore() {
// Store the content in the .gitignore.
file_put_contents( $this->work_tree . DIRECTORY_SEPARATOR . '.gitignore', revisr()->options['gitignore'] );
// Add the .gitignore.
$this->run( 'add', array( '.gitignore' ) );
// Convert the .gitignore into an array we can work with.
$files = explode( PHP_EOL, revisr()->options['gitignore'] );
foreach ( $files as $file ) {
if ( '' == $file || '!' === $file[0] ) {
// Don't do anything.
continue;
} else {
/**
* Remove the cached version of the file,
* leaving it intact in the working directory.
*/
$this->run( 'rm', array( '--cached', $file ) );
}
}
// Commit the updates.
$commit_msg = __( 'Updated .gitignore.', 'revisr' );
$this->run( 'commit', array( '-m', $commit_msg ) );
$this->auto_push();
}
/**
* Pings a remote repository to verify that it exists and is reachable.
* @access public
* @param string $remote The remote to ping.
*/
public function verify_remote( $remote = '' ) {
if ( $remote != '' ) {
$ping = $this->run( 'ls-remote', array( $remote, 'HEAD' ), __FUNCTION__ );
} else {
$ping = $this->run( 'ls-remote', array( $_REQUEST['remote'], 'HEAD' ), __FUNCTION__ );
}
return $ping;
}
/**
* Returns the current version of Git.
* @access public
*/
public function version() {
$version = $this->run( 'version', array(), __FUNCTION__ );
return $version;
}
}