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