1<?php
2
3/*
4 * Git.php
5 *
6 * A PHP git library
7 *
8 * @package    Git.php
9 * @version    0.1.4
10 * @author     James Brumond
11 * @copyright  Copyright 2013 James Brumond
12 * @repo       http://github.com/kbjr/Git.php
13 */
14
15if (__FILE__ == $_SERVER['SCRIPT_FILENAME']) die('Bad load order');
16
17// ------------------------------------------------------------------------
18
19/**
20 * Git Interface Class
21 *
22 * This class enables the creating, reading, and manipulation
23 * of git repositories.
24 *
25 * @class  Git
26 */
27class Git {
28
29	/**
30	 * Git executable location
31	 *
32	 * @var string
33	 */
34	protected static $bin = '/usr/bin/git';
35
36	/**
37	 * Sets git executable path
38	 *
39	 * @param string $path executable location
40	 */
41	public static function set_bin($path) {
42		self::$bin = $path;
43	}
44
45	/**
46	 * Gets git executable path
47	 */
48	public static function get_bin() {
49		return self::$bin;
50	}
51
52	/**
53	 * Sets up library for use in a default Windows environment
54	 */
55	public static function windows_mode() {
56		self::set_bin('git');
57	}
58
59	/**
60	 * Create a new git repository
61	 *
62	 * Accepts a creation path, and, optionally, a source path
63	 *
64	 * @access  public
65	 * @param   string  repository path
66	 * @param   string  directory to source
67	 * @param   \action_plugin_gitbacked_editcommit plugin
68	 * @return  GitRepo
69	 */
70	public static function &create($repo_path, $source = null, \action_plugin_gitbacked_editcommit $plugin = null) {
71		return GitRepo::create_new($repo_path, $source, $plugin);
72	}
73
74	/**
75	 * Open an existing git repository
76	 *
77	 * Accepts a repository path
78	 *
79	 * @access  public
80	 * @param   string  repository path
81	 * @param   \action_plugin_gitbacked_editcommit plugin
82	 * @return  GitRepo
83	 */
84	public static function open($repo_path, \action_plugin_gitbacked_editcommit $plugin = null) {
85		return new GitRepo($repo_path, $plugin);
86	}
87
88	/**
89	 * Clones a remote repo into a directory and then returns a GitRepo object
90	 * for the newly created local repo
91	 *
92	 * Accepts a creation path and a remote to clone from
93	 *
94	 * @access  public
95	 * @param   string  repository path
96	 * @param   string  remote source
97	 * @param   string  reference path
98	 * @param   \action_plugin_gitbacked_editcommit plugin
99	 * @return  GitRepo
100	 **/
101	public static function &clone_remote($repo_path, $remote, $reference = null, \action_plugin_gitbacked_editcommit $plugin = null) {
102		return GitRepo::create_new($repo_path, $plugin, $remote, true, $reference);
103	}
104
105	/**
106	 * Checks if a variable is an instance of GitRepo
107	 *
108	 * Accepts a variable
109	 *
110	 * @access  public
111	 * @param   mixed   variable
112	 * @return  bool
113	 */
114	public static function is_repo($var) {
115		return ($var instanceof GitRepo);
116	}
117
118}
119
120// ------------------------------------------------------------------------
121
122/**
123 * Git Repository Interface Class
124 *
125 * This class enables the creating, reading, and manipulation
126 * of a git repository
127 *
128 * @class  GitRepo
129 */
130class GitRepo {
131
132	// This regex will filter a probable password from any string containing a Git URL.
133	// Limitation: it will work for the first git URL occurrence in a string.
134	// Used https://regex101.com/ for evaluating!
135	const REGEX_GIT_URL_FILTER_PWD = "/^(.*)((http:)|(https:))([^:]+)(:[^@]*)?(.*)/im";
136	const REGEX_GIT_URL_FILTER_PWD_REPLACE_PATTERN = "$1$2$5$7";
137
138	protected $repo_path = null;
139	protected $bare = false;
140	protected $envopts = array();
141	protected ?\action_plugin_gitbacked_editcommit $plugin = null;
142
143	/**
144	 * Create a new git repository
145	 *
146	 * Accepts a creation path, and, optionally, a source path
147	 *
148	 * @access  public
149	 * @param   string  repository path
150	 * @param   \action_plugin_gitbacked_editcommit plugin
151	 * @param   string  directory to source
152	 * @param   string  reference path
153	 * @return  GitRepo  or null in case of an error
154	 */
155	public static function &create_new($repo_path, \action_plugin_gitbacked_editcommit $plugin = null, $source = null, $remote_source = false, $reference = null) {
156		if (is_dir($repo_path) && file_exists($repo_path."/.git") && is_dir($repo_path."/.git")) {
157			throw new Exception(self::handle_create_new_error($repo_path, $reference, '"'.$repo_path.'" is already a git repository', $plugin));
158		} else {
159			$repo = new self($repo_path, $plugin, true, false);
160			if (is_string($source)) {
161				if ($remote_source) {
162					if (!is_dir($reference) || !is_dir($reference.'/.git')) {
163						throw new Exception(self::handle_create_new_error($repo_path, $reference, '"'.$reference.'" is not a git repository. Cannot use as reference.', $plugin));
164					} else if (strlen($reference)) {
165						$reference = realpath($reference);
166						$reference = "--reference $reference";
167					}
168					$repo->clone_remote($source, $reference);
169				} else {
170					$repo->clone_from($source);
171				}
172			} else {
173				$repo->run('init');
174			}
175			return $repo;
176		}
177	}
178
179	/**
180	 * Constructor
181	 *
182	 * Accepts a repository path
183	 *
184	 * @access  public
185	 * @param   string  repository path
186	 * @param   \action_plugin_gitbacked_editcommit plugin
187	 * @param   bool    create if not exists?
188	 * @return  void
189	 */
190	public function __construct($repo_path = null, \action_plugin_gitbacked_editcommit $plugin = null, $create_new = false, $_init = true) {
191		$this->plugin = $plugin;
192		if (is_string($repo_path)) {
193			$this->set_repo_path($repo_path, $create_new, $_init);
194		}
195	}
196
197	/**
198	 * Set the repository's path
199	 *
200	 * Accepts the repository path
201	 *
202	 * @access  public
203	 * @param   string  repository path
204	 * @param   bool    create if not exists?
205	 * @param   bool    initialize new Git repo if not exists?
206	 * @return  void
207	 */
208	public function set_repo_path($repo_path, $create_new = false, $_init = true) {
209		if (is_string($repo_path)) {
210			if ($new_path = realpath($repo_path)) {
211				$repo_path = $new_path;
212				if (is_dir($repo_path)) {
213					// Is this a work tree?
214					if (file_exists($repo_path."/.git") && is_dir($repo_path."/.git")) {
215						$this->repo_path = $repo_path;
216						$this->bare = false;
217					// Is this a bare repo?
218					} else if (is_file($repo_path."/config")) {
219					  $parse_ini = parse_ini_file($repo_path."/config");
220						if ($parse_ini['bare']) {
221							$this->repo_path = $repo_path;
222							$this->bare = true;
223						}
224					} else {
225						if ($create_new) {
226							$this->repo_path = $repo_path;
227							if ($_init) {
228								$this->run('init');
229							}
230						} else {
231							throw new Exception($this->handle_repo_path_error($repo_path, '"'.$repo_path.'" is not a git repository'));
232						}
233					}
234				} else {
235					throw new Exception($this->handle_repo_path_error($repo_path, '"'.$repo_path.'" is not a directory'));
236				}
237			} else {
238				if ($create_new) {
239					if ($parent = realpath(dirname($repo_path))) {
240						mkdir($repo_path);
241						$this->repo_path = $repo_path;
242						if ($_init) $this->run('init');
243					} else {
244						throw new Exception($this->handle_repo_path_error($repo_path, 'cannot create repository in non-existent directory'));
245					}
246				} else {
247					throw new Exception($this->handle_repo_path_error($repo_path, '"'.$repo_path.'" does not exist'));
248				}
249			}
250		}
251	}
252
253	/**
254	 * Get the path to the git repo directory (eg. the ".git" directory)
255	 *
256	 * @access public
257	 * @return string
258	 */
259	public function git_directory_path() {
260		return ($this->bare) ? $this->repo_path : $this->repo_path."/.git";
261	}
262
263	/**
264	 * Tests if git is installed
265	 *
266	 * @access  public
267	 * @return  bool
268	 */
269	public function test_git() {
270		$descriptorspec = array(
271			1 => array('pipe', 'w'),
272			2 => array('pipe', 'w'),
273		);
274		$pipes = array();
275		$resource = proc_open(Git::get_bin(), $descriptorspec, $pipes);
276
277		$stdout = stream_get_contents($pipes[1]);
278		$stderr = stream_get_contents($pipes[2]);
279		foreach ($pipes as $pipe) {
280			fclose($pipe);
281		}
282
283		$status = trim(proc_close($resource));
284		return ($status != 127);
285	}
286
287	/**
288	 * Run a command in the git repository
289	 *
290	 * Accepts a shell command to run
291	 *
292	 * @access  protected
293	 * @param   string  command to run
294	 * @return  string  or null in case of an error
295	 */
296	protected function run_command($command) {
297		//dbglog("Git->run_command(command=[".$command."])");
298		$descriptorspec = array(
299			1 => array('pipe', 'w'),
300			2 => array('pipe', 'w'),
301		);
302		$pipes = array();
303		/* Depending on the value of variables_order, $_ENV may be empty.
304		 * In that case, we have to explicitly set the new variables with
305		 * putenv, and call proc_open with env=null to inherit the reset
306		 * of the system.
307		 *
308		 * This is kind of crappy because we cannot easily restore just those
309		 * variables afterwards.
310		 *
311		 * If $_ENV is not empty, then we can just copy it and be done with it.
312		 */
313		if(count($_ENV) === 0) {
314			$env = NULL;
315			foreach($this->envopts as $k => $v) {
316				putenv(sprintf("%s=%s",$k,$v));
317			}
318		} else {
319			$env = array_merge($_ENV, $this->envopts);
320		}
321		$cwd = $this->repo_path;
322		//dbglog("GitBacked - cwd: [".$cwd."]");
323		$resource = proc_open($command, $descriptorspec, $pipes, $cwd, $env);
324
325		$stdout = stream_get_contents($pipes[1]);
326		$stderr = stream_get_contents($pipes[2]);
327		foreach ($pipes as $pipe) {
328			fclose($pipe);
329		}
330
331		$status = trim(proc_close($resource));
332		//dbglog("GitBacked: run_command status: ".$status);
333		if ($status) {
334			//dbglog("GitBacked - stderr: [".$stderr."]");
335			// Remove a probable password from the Git URL, if the URL is contained in the error message
336			$error_message = preg_replace($this::REGEX_GIT_URL_FILTER_PWD, $this::REGEX_GIT_URL_FILTER_PWD_REPLACE_PATTERN, $stderr);
337			//dbglog("GitBacked - error_message: [".$error_message."]");
338			throw new Exception($this->handle_command_error($this->repo_path, $cwd, $command, $status, $error_message));
339		} else {
340			$this->handle_command_success($this->repo_path, $cwd, $command);
341		}
342
343		return $stdout;
344	}
345
346	/**
347	 * Run a git command in the git repository
348	 *
349	 * Accepts a git command to run
350	 *
351	 * @access  public
352	 * @param   string  command to run
353	 * @return  string
354	 */
355	public function run($command) {
356		return $this->run_command(Git::get_bin()." ".$command);
357	}
358
359	/**
360	 * Handles error on create_new
361	 *
362	 * @access  protected
363	 * @param   string  repository path
364	 * @param   string  error message
365	 * @return  string  error message
366	 */
367	protected static function handle_create_new_error($repo_path, $reference, $error_message, $plugin) {
368		if ($plugin instanceof \action_plugin_gitbacked_editcommit) {
369			$plugin->notify_create_new_error($repo_path, $reference, $error_message);
370		}
371		return $error_message;
372	}
373
374	/**
375	 * Handles error on setting the repo path
376	 *
377	 * @access  protected
378	 * @param   string  repository path
379	 * @param   string  error message
380	 * @return  string  error message
381	 */
382	protected function handle_repo_path_error($repo_path, $error_message) {
383		if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) {
384			$this->plugin->notify_repo_path_error($repo_path, $error_message);
385		}
386		return $error_message;
387	}
388
389	/**
390	 * Handles error on git command
391	 *
392	 * @access  protected
393	 * @param   string  repository path
394	 * @param   string  current working dir
395	 * @param   string  command line
396	 * @param   int     exit code of command (status)
397	 * @param   string  error message
398	 * @return  string  error message
399	 */
400	protected function handle_command_error($repo_path, $cwd, $command, $status, $error_message) {
401		if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) {
402			$this->plugin->notify_command_error($repo_path, $cwd, $command, $status, $error_message);
403		}
404		return $error_message;
405	}
406
407	/**
408	 * Handles success on git command
409	 *
410	 * @access  protected
411	 * @param   string  repository path
412	 * @param   string  current working dir
413	 * @param   string  command line
414	 * @return  void
415	 */
416	protected function handle_command_success($repo_path, $cwd, $command) {
417		if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) {
418			$this->plugin->notify_command_success($repo_path, $cwd, $command);
419		}
420	}
421
422	/**
423	 * Runs a 'git status' call
424	 *
425	 * Accept a convert to HTML bool
426	 *
427	 * @access public
428	 * @param bool  return string with <br />
429	 * @return string
430	 */
431	public function status($html = false) {
432		$msg = $this->run("status");
433		if ($html == true) {
434			$msg = str_replace("\n", "<br />", $msg);
435		}
436		return $msg;
437	}
438
439	/**
440	 * Runs a `git add` call
441	 *
442	 * Accepts a list of files to add
443	 *
444	 * @access  public
445	 * @param   mixed   files to add
446	 * @return  string
447	 */
448	public function add($files = "*") {
449		if (is_array($files)) {
450			$files = '"'.implode('" "', $files).'"';
451		}
452		return $this->run("add $files -v");
453	}
454
455	/**
456	 * Runs a `git rm` call
457	 *
458	 * Accepts a list of files to remove
459	 *
460	 * @access  public
461	 * @param   mixed    files to remove
462	 * @param   Boolean  use the --cached flag?
463	 * @return  string
464	 */
465	public function rm($files = "*", $cached = false) {
466		if (is_array($files)) {
467			$files = '"'.implode('" "', $files).'"';
468		}
469		return $this->run("rm ".($cached ? '--cached ' : '').$files);
470	}
471
472
473	/**
474	 * Runs a `git commit` call
475	 *
476	 * Accepts a commit message string
477	 *
478	 * @access  public
479	 * @param   string  commit message
480	 * @param   boolean  should all files be committed automatically (-a flag)
481	 * @return  string
482	 */
483	public function commit($message = "", $commit_all = true) {
484		$flags = $commit_all ? '-av' : '-v';
485		return $this->run("commit ".$flags." -m ".escapeshellarg($message));
486	}
487
488	/**
489	 * Runs a `git clone` call to clone the current repository
490	 * into a different directory
491	 *
492	 * Accepts a target directory
493	 *
494	 * @access  public
495	 * @param   string  target directory
496	 * @return  string
497	 */
498	public function clone_to($target) {
499		return $this->run("clone --local ".$this->repo_path." $target");
500	}
501
502	/**
503	 * Runs a `git clone` call to clone a different repository
504	 * into the current repository
505	 *
506	 * Accepts a source directory
507	 *
508	 * @access  public
509	 * @param   string  source directory
510	 * @return  string
511	 */
512	public function clone_from($source) {
513		return $this->run("clone --local $source ".$this->repo_path);
514	}
515
516	/**
517	 * Runs a `git clone` call to clone a remote repository
518	 * into the current repository
519	 *
520	 * Accepts a source url
521	 *
522	 * @access  public
523	 * @param   string  source url
524	 * @param   string  reference path
525	 * @return  string
526	 */
527	public function clone_remote($source, $reference) {
528		return $this->run("clone $reference $source ".$this->repo_path);
529	}
530
531	/**
532	 * Runs a `git clean` call
533	 *
534	 * Accepts a remove directories flag
535	 *
536	 * @access  public
537	 * @param   bool    delete directories?
538	 * @param   bool    force clean?
539	 * @return  string
540	 */
541	public function clean($dirs = false, $force = false) {
542		return $this->run("clean".(($force) ? " -f" : "").(($dirs) ? " -d" : ""));
543	}
544
545	/**
546	 * Runs a `git branch` call
547	 *
548	 * Accepts a name for the branch
549	 *
550	 * @access  public
551	 * @param   string  branch name
552	 * @return  string
553	 */
554	public function create_branch($branch) {
555		return $this->run("branch $branch");
556	}
557
558	/**
559	 * Runs a `git branch -[d|D]` call
560	 *
561	 * Accepts a name for the branch
562	 *
563	 * @access  public
564	 * @param   string  branch name
565	 * @return  string
566	 */
567	public function delete_branch($branch, $force = false) {
568		return $this->run("branch ".(($force) ? '-D' : '-d')." $branch");
569	}
570
571	/**
572	 * Runs a `git branch` call
573	 *
574	 * @access  public
575	 * @param   bool    keep asterisk mark on active branch
576	 * @return  array
577	 */
578	public function list_branches($keep_asterisk = false) {
579		$branchArray = explode("\n", $this->run("branch"));
580		foreach($branchArray as $i => &$branch) {
581			$branch = trim($branch);
582			if (! $keep_asterisk) {
583				$branch = str_replace("* ", "", $branch);
584			}
585			if ($branch == "") {
586				unset($branchArray[$i]);
587			}
588		}
589		return $branchArray;
590	}
591
592	/**
593	 * Lists remote branches (using `git branch -r`).
594	 *
595	 * Also strips out the HEAD reference (e.g. "origin/HEAD -> origin/master").
596	 *
597	 * @access  public
598	 * @return  array
599	 */
600	public function list_remote_branches() {
601		$branchArray = explode("\n", $this->run("branch -r"));
602		foreach($branchArray as $i => &$branch) {
603			$branch = trim($branch);
604			if ($branch == "" || strpos($branch, 'HEAD -> ') !== false) {
605				unset($branchArray[$i]);
606			}
607		}
608		return $branchArray;
609	}
610
611	/**
612	 * Returns name of active branch
613	 *
614	 * @access  public
615	 * @param   bool    keep asterisk mark on branch name
616	 * @return  string
617	 */
618	public function active_branch($keep_asterisk = false) {
619		$branchArray = $this->list_branches(true);
620		$active_branch = preg_grep("/^\*/", $branchArray);
621		reset($active_branch);
622		if ($keep_asterisk) {
623			return current($active_branch);
624		} else {
625			return str_replace("* ", "", current($active_branch));
626		}
627	}
628
629	/**
630	 * Runs a `git checkout` call
631	 *
632	 * Accepts a name for the branch
633	 *
634	 * @access  public
635	 * @param   string  branch name
636	 * @return  string
637	 */
638	public function checkout($branch) {
639		return $this->run("checkout $branch");
640	}
641
642
643	/**
644	 * Runs a `git merge` call
645	 *
646	 * Accepts a name for the branch to be merged
647	 *
648	 * @access  public
649	 * @param   string $branch
650	 * @return  string
651	 */
652	public function merge($branch) {
653		return $this->run("merge $branch --no-ff");
654	}
655
656
657	/**
658	 * Runs a git fetch on the current branch
659	 *
660	 * @access  public
661	 * @return  string
662	 */
663	public function fetch() {
664		return $this->run("fetch");
665	}
666
667	/**
668	 * Add a new tag on the current position
669	 *
670	 * Accepts the name for the tag and the message
671	 *
672	 * @param string $tag
673	 * @param string $message
674	 * @return string
675	 */
676	public function add_tag($tag, $message = null) {
677		if ($message === null) {
678			$message = $tag;
679		}
680		return $this->run("tag -a $tag -m " . escapeshellarg($message));
681	}
682
683	/**
684	 * List all the available repository tags.
685	 *
686	 * Optionally, accept a shell wildcard pattern and return only tags matching it.
687	 *
688	 * @access	public
689	 * @param	string	$pattern	Shell wildcard pattern to match tags against.
690	 * @return	array				Available repository tags.
691	 */
692	public function list_tags($pattern = null) {
693		$tagArray = explode("\n", $this->run("tag -l $pattern"));
694		foreach ($tagArray as $i => &$tag) {
695			$tag = trim($tag);
696			if ($tag == '') {
697				unset($tagArray[$i]);
698			}
699		}
700
701		return $tagArray;
702	}
703
704	/**
705	 * Push specific branch to a remote
706	 *
707	 * Accepts the name of the remote and local branch
708	 *
709	 * @param string $remote
710	 * @param string $branch
711	 * @return string
712	 */
713	public function push($remote, $branch) {
714		return $this->run("push --tags $remote $branch");
715	}
716
717	/**
718	 * Pull specific branch from remote
719	 *
720	 * Accepts the name of the remote and local branch
721	 *
722	 * @param string $remote
723	 * @param string $branch
724	 * @return string
725	 */
726	public function pull($remote, $branch) {
727		return $this->run("pull $remote $branch");
728	}
729
730	/**
731	 * List log entries.
732	 *
733	 * @param strgin $format
734	 * @return string
735	 */
736	public function log($format = null) {
737		if ($format === null)
738			return $this->run('log');
739		else
740			return $this->run('log --pretty=format:"' . $format . '"');
741	}
742
743	/**
744	 * Sets the project description.
745	 *
746	 * @param string $new
747	 */
748	public function set_description($new) {
749		$path = $this->git_directory_path();
750		file_put_contents($path."/description", $new);
751	}
752
753	/**
754	 * Gets the project description.
755	 *
756	 * @return string
757	 */
758	public function get_description() {
759		$path = $this->git_directory_path();
760		return file_get_contents($path."/description");
761	}
762
763	/**
764	 * Sets custom environment options for calling Git
765	 *
766	 * @param string key
767	 * @param string value
768	 */
769	public function setenv($key, $value) {
770		$this->envopts[$key] = $value;
771	}
772
773}
774
775/* End of file */
776