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