<?php

/*
 * Git.php
 *
 * A PHP git library
 *
 * @package    Git.php
 * @version    0.1.4
 * @author     James Brumond
 * @copyright  Copyright 2013 James Brumond
 * @repo       http://github.com/kbjr/Git.php
 */

if (__FILE__ == $_SERVER['SCRIPT_FILENAME']) die('Bad load order');

// ------------------------------------------------------------------------

/**
* Git Interface Class
*
* This class enables the creating, reading, and manipulation
* of git repositories.
*
* @class  Git
*/
class Git {

	/**
	* Git executable location
	*
	* @var string
	*/
	// protected static $bin = '/usr/bin/git';
	protected static $bin = 'git';

	/**
	* Sets git executable path
	*
	* @param string $path executable location
	*/
	public static function set_bin($path) {
		self::$bin = $path;
	}

	/**
	* Gets git executable path
	*/
	public static function get_bin() {
		return self::$bin;
	}

	/**
	* Create a new git repository
	*
	* Accepts a creation path, and, optionally, a source path
	*
	* @access  public
	* @param   string  repository path
	* @param   string  directory to source
	* @return  GitRepo
	*/
	public static function &create($repo_path, $source = null) {
		return GitRepo::create_new($repo_path, $source);
	}

	/**
	* Open an existing git repository
	*
	* Accepts a repository path
	*
	* @access  public
	* @param   string  repository path
	* @return  GitRepo
	*/
	public static function open($repo_path) {
		return new GitRepo($repo_path);
	}

	/**
	* Clones a remote repo into a directory and then returns a GitRepo object
	* for the newly created local repo
	*
	* Accepts a creation path and a remote to clone from
	*
	* @access  public
	* @param   string  repository path
	* @param   string  remote source
	* @param   string  reference path
	* @return  GitRepo
	**/
	public static function &clone_remote($repo_path, $remote, $reference = null) {
		//Changed the below boolean from true to false, since this appears to be a bug when not using a reference repo.  A more robust solution may be appropriate to make it work with AND without a reference.
		return GitRepo::create_new($repo_path, $remote, false, $reference);
	}

	/**
	* Checks if a variable is an instance of GitRepo
	*
	* Accepts a variable
	*
	* @access  public
	* @param   mixed   variable
	* @return  bool
	*/
	public static function is_repo($var) {
		return (get_class($var) == 'GitRepo');
	}

}

// ------------------------------------------------------------------------

/**
* Git Repository Interface Class
*
* This class enables the creating, reading, and manipulation
* of a git repository
*
* @class  GitRepo
*/
class GitRepo {

	protected $repo_path = null;
	protected $bare = false;
	protected $envopts = array();

	/**
	* Create a new git repository
	*
	* Accepts a creation path, and, optionally, a source path
	*
	* @access  public
	* @param   string  repository path
	* @param   string  directory to source
	* @param   string  reference path
	* @return  GitRepo
	*/
	public static function &create_new($repo_path, $source = null, $remote_source = false, $reference = null) {
		if (is_dir($repo_path) && file_exists($repo_path."/.git")) {
			throw new Exception('"'.$repo_path.'" is already a git repository');
		} else {
			$repo = new self($repo_path, true, false);
			if (is_string($source)) {
				if ($remote_source) {
					if (isset($reference)) {
						if (!is_dir($reference) || !is_dir($reference.'/.git')) {
							throw new Exception('"'.$reference.'" is not a git repository. Cannot use as reference.');
						} else if (strlen($reference)) {
							$reference = realpath($reference);
							$reference = "--reference $reference";
						}
					}
					$repo->clone_remote($source, $reference);
				} else {
					$repo->clone_from($source);
				}
			} else {
				$repo->run('init');
			}
			return $repo;
		}
	}

	/**
	* Constructor
	*
	* Accepts a repository path
	*
	* @access  public
	* @param   string  repository path
	* @param   bool    create if not exists?
	* @return  void
	*/
	public function __construct($repo_path = null, $create_new = false, $_init = true) {
		if (is_string($repo_path)) {
			$this->set_repo_path($repo_path, $create_new, $_init);
		}
	}

	/**
	* Set the repository's path
	*
	* Accepts the repository path
	*
	* @access  public
	* @param   string  repository path
	* @param   bool    create if not exists?
	* @param   bool    initialize new Git repo if not exists?
	* @return  void
	*/
	public function set_repo_path($repo_path, $create_new = false, $_init = true) {
		if (is_string($repo_path)) {
			if ($new_path = realpath($repo_path)) {
				$repo_path = $new_path;
				if (is_dir($repo_path)) {
					// Is this a work tree?
					if (file_exists($repo_path."/.git")) {
						$this->repo_path = $repo_path;
						$this->bare = false;
						// Is this a bare repo?
					} else if (is_file($repo_path."/config")) {
						$parse_ini = parse_ini_file($repo_path."/config");
						if ($parse_ini['bare']) {
							$this->repo_path = $repo_path;
							$this->bare = true;
						}
					} else {
						if ($create_new) {
							$this->repo_path = $repo_path;
							if ($_init) {
								$this->run('init');
							}
						} else {
							throw new Exception('"'.$repo_path.'" is not a git repository');
						}
					}
				} else {
					throw new Exception('"'.$repo_path.'" is not a directory');
				}
			} else {
				if ($create_new) {
					if ($parent = realpath(dirname($repo_path))) {
						mkdir($repo_path);
						$this->repo_path = $repo_path;
						if ($_init) $this->run('init');
					} else {
						throw new Exception('cannot create repository in non-existent directory');
					}
				} else {
					throw new Exception('"'.$repo_path.'" does not exist');
				}
			}
		}
	}

	/**
	* Get the path to the git repo directory (eg. the ".git" directory)
	*
	* @access public
	* @return string
	*/
	public function git_directory_path() {
		if ($this->bare) {
			return $this->repo_path;
		} else if (is_dir($this->repo_path."/.git")) {
			return $this->repo_path."/.git";
		} else if (is_file($this->repo_path."/.git")) {
			$git_file = file_get_contents($this->repo_path."/.git");
			if (mb_ereg("^gitdir: (.+)$", $git_file, $matches)) {
				if ($matches[1]) {
					$rel_git_path = $matches[1];
					return $this->repo_path."/".$rel_git_path;
				}
			}
		}
		throw new Exception('could not find git dir for '.$this->repo_path.'.');
	}

	/**
	* Tests if git is installed
	*
	* @access  public
	* @return  bool
	*/
	public function test_git() {
		$descriptorspec = array(
			1 => array('pipe', 'w'),
			2 => array('pipe', 'w'),
		);
		$pipes = array();
		$resource = proc_open(Git::get_bin(), $descriptorspec, $pipes);

		$stdout = stream_get_contents($pipes[1]);
		$stderr = stream_get_contents($pipes[2]);
		foreach ($pipes as $pipe) {
			fclose($pipe);
		}

		$status = trim(proc_close($resource));
		return ($status != 127);
	}

	/**
	* Run a command in the git repository
	*
	* Accepts a shell command to run
	*
	* @access  protected
	* @param   string  command to run
	* @return  string
	*/
	protected function run_command($command) {
		$descriptorspec = array(
			1 => array('pipe', 'w'),
			2 => array('pipe', 'w'),
		);
		$pipes = array();
		/* Depending on the value of variables_order, $_ENV may be empty.
		 * In that case, we have to explicitly set the new variables with
		 * putenv, and call proc_open with env=null to inherit the reset
		 * of the system.
		 *
		 * This is kind of crappy because we cannot easily restore just those
		 * variables afterwards.
		 *
		 * If $_ENV is not empty, then we can just copy it and be done with it.
		 */
		if (count($_ENV) === 0) {
			$env = NULL;
			foreach ($this->envopts as $k => $v) {
				putenv(sprintf("%s=%s", $k, $v));
			}
		} else {
			$env = array_merge($_ENV, $this->envopts);
		}
		$cwd = $this->repo_path;
		$resource = proc_open($command, $descriptorspec, $pipes, $cwd, $env);

		$stdout = stream_get_contents($pipes[1]);
		$stderr = stream_get_contents($pipes[2]);
		foreach ($pipes as $pipe) {
			fclose($pipe);
		}

		$status = trim(proc_close($resource));
		if ($status) throw new Exception($stderr . "\n" . $stdout); //Not all errors are printed to stderr, so include std out as well.

		return $stdout;
	}

	/**
	* Run a git command in the git repository
	*
	* Accepts a git command to run
	*
	* @access  public
	* @param   string  command to run
	* @return  string
	*/
	public function run($command) {
		return $this->run_command(Git::get_bin()." ".$command);
	}

	/**
	* Runs a 'git status' call
	*
	* Accept a convert to HTML bool
	*
	* @access public
	* @param bool  return string with <br />
	* @return string
	*/
	public function status($html = false) {
		$msg = $this->run("status");
		if ($html == true) {
			$msg = str_replace("\n", "<br />", $msg);
		}
		return $msg;
	}

	/**
	* Runs a `git add` call
	*
	* Accepts a list of files to add
	*
	* @access  public
	* @param   mixed   files to add
	* @return  string
	*/
	public function add($files = "*") {
		if (is_array($files)) {
			$files = '"'.implode('" "', $files).'"';
		}
		return $this->run("add$files -v");
	}

	/**
	* Runs a `git rm` call
	*
	* Accepts a list of files to remove
	*
	* @access  public
	* @param   mixed    files to remove
	* @param   Boolean  use the --cached flag?
	* @return  string
	*/
	public function rm($files = "*", $cached = false) {
		if (is_array($files)) {
			$files = '"'.implode('" "', $files).'"';
		}
		return $this->run("rm ".($cached ? '--cached ' : '').$files);
	}


	/**
	* Runs a `git commit` call
	*
	* Accepts a commit message string
	*
	* @access  public
	* @param   string  commit message
	* @param   boolean  should all files be committed automatically (-a flag)
	* @return  string
	*/
	public function commit($message = "", $commit_all = true) {
		$flags = $commit_all ? '-av' : '-v';
		return $this->run("commit ".$flags." -m ".escapeshellarg($message));
	}

	/**
	* Runs a `git clone` call to clone the current repository
	* into a different directory
	*
	* Accepts a target directory
	*
	* @access  public
	* @param   string  target directory
	* @return  string
	*/
	public function clone_to($target) {
		return $this->run("clone --local ".$this->repo_path." $target");
	}

	/**
	* Runs a `git clone` call to clone a different repository
	* into the current repository
	*
	* Accepts a source directory
	*
	* @access  public
	* @param   string  source directory
	* @return  string
	*/
	public function clone_from($source) {
		return $this->run("clone --local$source ".$this->repo_path);
	}

	/**
	* Runs a `git clone` call to clone a remote repository
	* into the current repository
	*
	* Accepts a source url
	*
	* @access  public
	* @param   string  source url
	* @param   string  reference path
	* @return  string
	*/
	public function clone_remote($source, $reference) {
		return $this->run("clone$reference $source ".$this->repo_path);
	}

	/**
	* Runs a `git clean` call
	*
	* Accepts a remove directories flag
	*
	* @access  public
	* @param   bool    delete directories?
	* @param   bool    force clean?
	* @return  string
	*/
	public function clean($dirs = false, $force = false) {
		return $this->run("clean".(($force) ? " -f" : "").(($dirs) ? " -d" : ""));
	}

	/**
	* Runs a `git branch` call
	*
	* Accepts a name for the branch
	*
	* @access  public
	* @param   string  branch name
	* @return  string
	*/
	public function create_branch($branch) {
		return $this->run("branch " . escapeshellarg($branch));
	}

	/**
	* Runs a `git branch -[d|D]` call
	*
	* Accepts a name for the branch
	*
	* @access  public
	* @param   string  branch name
	* @return  string
	*/
	public function delete_branch($branch, $force = false) {
		return $this->run("branch ".(($force) ? '-D' : '-d')." $branch");
	}

	/**
	* Runs a `git branch` call
	*
	* @access  public
	* @param   bool    keep asterisk mark on active branch
	* @return  array
	*/
	public function list_branches($keep_asterisk = false) {
		$branchArray = explode("\n", $this->run("branch"));
		foreach ($branchArray as $i => &$branch) {
			$branch = trim($branch);
			if (! $keep_asterisk) {
				$branch = str_replace("* ", "", $branch);
			}
			if ($branch == "") {
				unset($branchArray[$i]);
			}
		}
		return $branchArray;
	}

	/**
	* Lists remote branches (using `git branch -r`).
	*
	* Also strips out the HEAD reference (e.g. "origin/HEAD -> origin/master").
	*
	* @access  public
	* @return  array
	*/
	public function list_remote_branches() {
		$branchArray = explode("\n", $this->run("branch -r"));
		foreach ($branchArray as $i => &$branch) {
			$branch = trim($branch);
			if ($branch == "" || strpos($branch, 'HEAD -> ') !== false) {
				unset($branchArray[$i]);
			}
		}
		return $branchArray;
	}

	/**
	* Returns name of active branch
	*
	* @access  public
	* @param   bool    keep asterisk mark on branch name
	* @return  string
	*/
	public function active_branch($keep_asterisk = false) {
		$branchArray = $this->list_branches(true);
		$active_branch = preg_grep("/^\*/", $branchArray);
		reset($active_branch);
		if ($keep_asterisk) {
			return current($active_branch);
		} else {
			return str_replace("* ", "", current($active_branch));
		}
	}

	/**
	* Runs a `git checkout` call
	*
	* Accepts a name for the branch
	*
	* @access  public
	* @param   string  branch name
	* @return  string
	*/
	public function checkout($branch) {
		return $this->run("checkout " . escapeshellarg($branch));
	}

	/**
	* Runs a `git checkout` call against a specific file
	*
	* Allows you to revert a named file to a specific revision
	*
	* Accepts a name for the branch
	*
	* @access  public
	* @param   string  branch name
	* @return  string
	*/
	public function checkoutFile($revision, $file, $message = null) {
		$this->run("checkout$revision $file");
		// We don't need to do an Add as git

		if ($message == null) {
			$message = "Reverted $file to revision $revision";
		}

		return $this->commit($message);
	}


	/**
	* Runs a `git merge` call
	*
	* Accepts a name for the branch to be merged
	*
	* @access  public
	* @param   string $branch
	* @return  string
	*/
	public function merge($branch) {
		return $this->run("merge " . escapeshellarg($branch) . " --no-ff");
	}


	/**
	* Runs a git fetch on the current branch
	*
	* @access  public
	* @return  string
	*/
	public function fetch() {
		return $this->run("fetch");
	}

	/**
	* Add a new tag on the current position
	*
	* Accepts the name for the tag and the message
	*
	* @param string $tag
	* @param string $message
	* @return string
	*/
	public function add_tag($tag, $message = null) {
		if (is_null($message)) {
			$message = $tag;
		}
		return $this->run("tag -a$tag -m " . escapeshellarg($message));
	}

	/**
	* List all the available repository tags.
	*
	* Optionally, accept a shell wildcard pattern and return only tags matching it.
	*
	* @access	public
	* @param	string	$pattern	Shell wildcard pattern to match tags against.
	* @return	array				Available repository tags.
	*/
	public function list_tags($pattern = null) {
		$tagArray = explode("\n", $this->run("tag -l$pattern"));
		foreach ($tagArray as $i => &$tag) {
			$tag = trim($tag);
			if (empty($tag)) {
				unset($tagArray[$i]);
			}
		}

		return $tagArray;
	}

	/**
	* Push specific branch (or all branches) to a remote
	*
	* Accepts the name of the remote and local branch.
	* If omitted, the command will be "git push", and therefore will take
	* on the behavior of your "push.defualt" configuration setting.
	*
	* @param string $remote
	* @param string $branch
	* @return string
	*/
	public function push($remote = "", $branch = "") {
		//--tags removed since this was preventing branches from being pushed (only tags were)
		return $this->run("push$remote $branch");
	}

	/**
	* Pull specific branch from remote
	*
	* Accepts the name of the remote and local branch.
	* If omitted, the command will be "git pull", and therefore will take on the
	* behavior as-configured in your clone / environment.
	*
	* @param string $remote
	* @param string $branch
	* @return string
	*/
	public function pull($remote = "", $branch = "") {
		return $this->run("pull$remote $branch");
	}

	/**
	* List log entries.
	*
	* @param strgin $format
	* @return string
	*/
	public function log($format = null, $fulldiff = false, $filepath = null, $follow = false) {
		$diff = "";

		if ($fulldiff) {
			$diff = "--full-diff -p ";
		}

		if ($follow) {
			// Can't use full-diff with follow
			$diff = "--follow -- ";
		}

		if ($format === null)
			return $this->run('log ' . $diff . $filepath);
		else
			return $this->run('log --pretty=format:"' . $format . '" ' . $diff .$filepath);
	}

	/**
	* Sets the project description.
	*
	* @param string $new
	*/
	public function set_description($new) {
		$path = $this->git_directory_path();
		file_put_contents($path."/description", $new);
	}

	/**
	* Gets the project description.
	*
	* @return string
	*/
	public function get_description() {
		$path = $this->git_directory_path();
		return file_get_contents($path."/description");
	}

	/**
	* Sets custom environment options for calling Git
	*
	* @param string key
	* @param string value
	*/
	public function setenv($key, $value) {
		$this->envopts[$key] = $value;
	}

}

/* End of file */