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 $cwd = $this->repo_path; 306 //dbglog("GitBacked - cwd: [".$cwd."]"); 307 /* Provide any $this->envopts via putenv 308 * and call proc_open with env=null to inherit the rest 309 * of env variables from the original process of the system. 310 * Note: Variables set by putenv live for a 311 * single PHP request run only. These variables 312 * are visible "locally". They are NOT listed by getenv(), 313 * but they are visible to the process forked by proc_open(). 314 */ 315 foreach($this->envopts as $k => $v) { 316 putenv(sprintf("%s=%s",$k,$v)); 317 } 318 $resource = proc_open($command, $descriptorspec, $pipes, $cwd, null); 319 320 $stdout = stream_get_contents($pipes[1]); 321 $stderr = stream_get_contents($pipes[2]); 322 foreach ($pipes as $pipe) { 323 fclose($pipe); 324 } 325 326 $status = trim(proc_close($resource)); 327 //dbglog("GitBacked: run_command status: ".$status); 328 if ($status) { 329 //dbglog("GitBacked - stderr: [".$stderr."]"); 330 // Remove a probable password from the Git URL, if the URL is contained in the error message 331 $error_message = preg_replace($this::REGEX_GIT_URL_FILTER_PWD, $this::REGEX_GIT_URL_FILTER_PWD_REPLACE_PATTERN, $stderr); 332 //dbglog("GitBacked - error_message: [".$error_message."]"); 333 throw new Exception($this->handle_command_error($this->repo_path, $cwd, $command, $status, $error_message)); 334 } else { 335 $this->handle_command_success($this->repo_path, $cwd, $command); 336 } 337 338 return $stdout; 339 } 340 341 /** 342 * Run a git command in the git repository 343 * 344 * Accepts a git command to run 345 * 346 * @access public 347 * @param string command to run 348 * @return string 349 */ 350 public function run($command) { 351 return $this->run_command(Git::get_bin()." ".$command); 352 } 353 354 /** 355 * Handles error on create_new 356 * 357 * @access protected 358 * @param string repository path 359 * @param string error message 360 * @return string error message 361 */ 362 protected static function handle_create_new_error($repo_path, $reference, $error_message, $plugin) { 363 if ($plugin instanceof \action_plugin_gitbacked_editcommit) { 364 $plugin->notify_create_new_error($repo_path, $reference, $error_message); 365 } 366 return $error_message; 367 } 368 369 /** 370 * Handles error on setting the repo path 371 * 372 * @access protected 373 * @param string repository path 374 * @param string error message 375 * @return string error message 376 */ 377 protected function handle_repo_path_error($repo_path, $error_message) { 378 if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) { 379 $this->plugin->notify_repo_path_error($repo_path, $error_message); 380 } 381 return $error_message; 382 } 383 384 /** 385 * Handles error on git command 386 * 387 * @access protected 388 * @param string repository path 389 * @param string current working dir 390 * @param string command line 391 * @param int exit code of command (status) 392 * @param string error message 393 * @return string error message 394 */ 395 protected function handle_command_error($repo_path, $cwd, $command, $status, $error_message) { 396 if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) { 397 $this->plugin->notify_command_error($repo_path, $cwd, $command, $status, $error_message); 398 } 399 return $error_message; 400 } 401 402 /** 403 * Handles success on git command 404 * 405 * @access protected 406 * @param string repository path 407 * @param string current working dir 408 * @param string command line 409 * @return void 410 */ 411 protected function handle_command_success($repo_path, $cwd, $command) { 412 if ($this->plugin instanceof \action_plugin_gitbacked_editcommit) { 413 $this->plugin->notify_command_success($repo_path, $cwd, $command); 414 } 415 } 416 417 /** 418 * Runs a 'git status' call 419 * 420 * Accept a convert to HTML bool 421 * 422 * @access public 423 * @param bool return string with <br /> 424 * @return string 425 */ 426 public function status($html = false) { 427 $msg = $this->run("status"); 428 if ($html == true) { 429 $msg = str_replace("\n", "<br />", $msg); 430 } 431 return $msg; 432 } 433 434 /** 435 * Runs a `git add` call 436 * 437 * Accepts a list of files to add 438 * 439 * @access public 440 * @param mixed files to add 441 * @return string 442 */ 443 public function add($files = "*") { 444 if (is_array($files)) { 445 $files = '"'.implode('" "', $files).'"'; 446 } 447 return $this->run("add $files -v"); 448 } 449 450 /** 451 * Runs a `git rm` call 452 * 453 * Accepts a list of files to remove 454 * 455 * @access public 456 * @param mixed files to remove 457 * @param Boolean use the --cached flag? 458 * @return string 459 */ 460 public function rm($files = "*", $cached = false) { 461 if (is_array($files)) { 462 $files = '"'.implode('" "', $files).'"'; 463 } 464 return $this->run("rm ".($cached ? '--cached ' : '').$files); 465 } 466 467 468 /** 469 * Runs a `git commit` call 470 * 471 * Accepts a commit message string 472 * 473 * @access public 474 * @param string commit message 475 * @param boolean should all files be committed automatically (-a flag) 476 * @return string 477 */ 478 public function commit($message = "", $commit_all = true) { 479 $flags = $commit_all ? '-av' : '-v'; 480 $msgfile = GitBackedUtil::createMessageFile($message); 481 try { 482 return $this->run("commit --allow-empty ".$flags." --file=".$msgfile); 483 } finally { 484 unlink($msgfile); 485 } 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 $msgfile = GitBackedUtil::createMessageFile($message); 681 try { 682 return $this->run("tag -a $tag --file=".$msgfile); 683 } finally { 684 unlink($msgfile); 685 } 686 } 687 688 /** 689 * List all the available repository tags. 690 * 691 * Optionally, accept a shell wildcard pattern and return only tags matching it. 692 * 693 * @access public 694 * @param string $pattern Shell wildcard pattern to match tags against. 695 * @return array Available repository tags. 696 */ 697 public function list_tags($pattern = null) { 698 $tagArray = explode("\n", $this->run("tag -l $pattern")); 699 foreach ($tagArray as $i => &$tag) { 700 $tag = trim($tag); 701 if ($tag == '') { 702 unset($tagArray[$i]); 703 } 704 } 705 706 return $tagArray; 707 } 708 709 /** 710 * Push specific branch to a remote 711 * 712 * Accepts the name of the remote and local branch 713 * 714 * @param string $remote 715 * @param string $branch 716 * @return string 717 */ 718 public function push($remote, $branch) { 719 return $this->run("push --tags $remote $branch"); 720 } 721 722 /** 723 * Pull specific branch from remote 724 * 725 * Accepts the name of the remote and local branch 726 * 727 * @param string $remote 728 * @param string $branch 729 * @return string 730 */ 731 public function pull($remote, $branch) { 732 return $this->run("pull $remote $branch"); 733 } 734 735 /** 736 * List log entries. 737 * 738 * @param strgin $format 739 * @return string 740 */ 741 public function log($format = null) { 742 if ($format === null) 743 return $this->run('log'); 744 else 745 return $this->run('log --pretty=format:"' . $format . '"'); 746 } 747 748 /** 749 * Sets the project description. 750 * 751 * @param string $new 752 */ 753 public function set_description($new) { 754 $path = $this->git_directory_path(); 755 file_put_contents($path."/description", $new); 756 } 757 758 /** 759 * Gets the project description. 760 * 761 * @return string 762 */ 763 public function get_description() { 764 $path = $this->git_directory_path(); 765 return file_get_contents($path."/description"); 766 } 767 768 /** 769 * Sets custom environment options for calling Git 770 * 771 * @param string key 772 * @param string value 773 */ 774 public function setenv($key, $value) { 775 $this->envopts[$key] = $value; 776 } 777 778} 779 780/* End of file */ 781