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