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