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 return $this->run("commit --allow-empty ".$flags." -m ".escapeshellarg($message)); 488 } 489 490 /** 491 * Runs a `git clone` call to clone the current repository 492 * into a different directory 493 * 494 * Accepts a target directory 495 * 496 * @access public 497 * @param string target directory 498 * @return string 499 */ 500 public function clone_to($target) { 501 return $this->run("clone --local ".$this->repo_path." $target"); 502 } 503 504 /** 505 * Runs a `git clone` call to clone a different repository 506 * into the current repository 507 * 508 * Accepts a source directory 509 * 510 * @access public 511 * @param string source directory 512 * @return string 513 */ 514 public function clone_from($source) { 515 return $this->run("clone --local $source ".$this->repo_path); 516 } 517 518 /** 519 * Runs a `git clone` call to clone a remote repository 520 * into the current repository 521 * 522 * Accepts a source url 523 * 524 * @access public 525 * @param string source url 526 * @param string reference path 527 * @return string 528 */ 529 public function clone_remote($source, $reference) { 530 return $this->run("clone $reference $source ".$this->repo_path); 531 } 532 533 /** 534 * Runs a `git clean` call 535 * 536 * Accepts a remove directories flag 537 * 538 * @access public 539 * @param bool delete directories? 540 * @param bool force clean? 541 * @return string 542 */ 543 public function clean($dirs = false, $force = false) { 544 return $this->run("clean".(($force) ? " -f" : "").(($dirs) ? " -d" : "")); 545 } 546 547 /** 548 * Runs a `git branch` call 549 * 550 * Accepts a name for the branch 551 * 552 * @access public 553 * @param string branch name 554 * @return string 555 */ 556 public function create_branch($branch) { 557 return $this->run("branch $branch"); 558 } 559 560 /** 561 * Runs a `git branch -[d|D]` call 562 * 563 * Accepts a name for the branch 564 * 565 * @access public 566 * @param string branch name 567 * @return string 568 */ 569 public function delete_branch($branch, $force = false) { 570 return $this->run("branch ".(($force) ? '-D' : '-d')." $branch"); 571 } 572 573 /** 574 * Runs a `git branch` call 575 * 576 * @access public 577 * @param bool keep asterisk mark on active branch 578 * @return array 579 */ 580 public function list_branches($keep_asterisk = false) { 581 $branchArray = explode("\n", $this->run("branch")); 582 foreach($branchArray as $i => &$branch) { 583 $branch = trim($branch); 584 if (! $keep_asterisk) { 585 $branch = str_replace("* ", "", $branch); 586 } 587 if ($branch == "") { 588 unset($branchArray[$i]); 589 } 590 } 591 return $branchArray; 592 } 593 594 /** 595 * Lists remote branches (using `git branch -r`). 596 * 597 * Also strips out the HEAD reference (e.g. "origin/HEAD -> origin/master"). 598 * 599 * @access public 600 * @return array 601 */ 602 public function list_remote_branches() { 603 $branchArray = explode("\n", $this->run("branch -r")); 604 foreach($branchArray as $i => &$branch) { 605 $branch = trim($branch); 606 if ($branch == "" || strpos($branch, 'HEAD -> ') !== false) { 607 unset($branchArray[$i]); 608 } 609 } 610 return $branchArray; 611 } 612 613 /** 614 * Returns name of active branch 615 * 616 * @access public 617 * @param bool keep asterisk mark on branch name 618 * @return string 619 */ 620 public function active_branch($keep_asterisk = false) { 621 $branchArray = $this->list_branches(true); 622 $active_branch = preg_grep("/^\*/", $branchArray); 623 reset($active_branch); 624 if ($keep_asterisk) { 625 return current($active_branch); 626 } else { 627 return str_replace("* ", "", current($active_branch)); 628 } 629 } 630 631 /** 632 * Runs a `git checkout` call 633 * 634 * Accepts a name for the branch 635 * 636 * @access public 637 * @param string branch name 638 * @return string 639 */ 640 public function checkout($branch) { 641 return $this->run("checkout $branch"); 642 } 643 644 645 /** 646 * Runs a `git merge` call 647 * 648 * Accepts a name for the branch to be merged 649 * 650 * @access public 651 * @param string $branch 652 * @return string 653 */ 654 public function merge($branch) { 655 return $this->run("merge $branch --no-ff"); 656 } 657 658 659 /** 660 * Runs a git fetch on the current branch 661 * 662 * @access public 663 * @return string 664 */ 665 public function fetch() { 666 return $this->run("fetch"); 667 } 668 669 /** 670 * Add a new tag on the current position 671 * 672 * Accepts the name for the tag and the message 673 * 674 * @param string $tag 675 * @param string $message 676 * @return string 677 */ 678 public function add_tag($tag, $message = null) { 679 if ($message === null) { 680 $message = $tag; 681 } 682 return $this->run("tag -a $tag -m " . escapeshellarg($message)); 683 } 684 685 /** 686 * List all the available repository tags. 687 * 688 * Optionally, accept a shell wildcard pattern and return only tags matching it. 689 * 690 * @access public 691 * @param string $pattern Shell wildcard pattern to match tags against. 692 * @return array Available repository tags. 693 */ 694 public function list_tags($pattern = null) { 695 $tagArray = explode("\n", $this->run("tag -l $pattern")); 696 foreach ($tagArray as $i => &$tag) { 697 $tag = trim($tag); 698 if ($tag == '') { 699 unset($tagArray[$i]); 700 } 701 } 702 703 return $tagArray; 704 } 705 706 /** 707 * Push specific branch to a remote 708 * 709 * Accepts the name of the remote and local branch 710 * 711 * @param string $remote 712 * @param string $branch 713 * @return string 714 */ 715 public function push($remote, $branch) { 716 return $this->run("push --tags $remote $branch"); 717 } 718 719 /** 720 * Pull specific branch from remote 721 * 722 * Accepts the name of the remote and local branch 723 * 724 * @param string $remote 725 * @param string $branch 726 * @return string 727 */ 728 public function pull($remote, $branch) { 729 return $this->run("pull $remote $branch"); 730 } 731 732 /** 733 * List log entries. 734 * 735 * @param strgin $format 736 * @return string 737 */ 738 public function log($format = null) { 739 if ($format === null) 740 return $this->run('log'); 741 else 742 return $this->run('log --pretty=format:"' . $format . '"'); 743 } 744 745 /** 746 * Sets the project description. 747 * 748 * @param string $new 749 */ 750 public function set_description($new) { 751 $path = $this->git_directory_path(); 752 file_put_contents($path."/description", $new); 753 } 754 755 /** 756 * Gets the project description. 757 * 758 * @return string 759 */ 760 public function get_description() { 761 $path = $this->git_directory_path(); 762 return file_get_contents($path."/description"); 763 } 764 765 /** 766 * Sets custom environment options for calling Git 767 * 768 * @param string key 769 * @param string value 770 */ 771 public function setenv($key, $value) { 772 $this->envopts[$key] = $value; 773 } 774 775} 776 777/* End of file */ 778