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