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