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