[ Index ] |
PHP Cross Reference of Phabricator |
[Summary view] [Print] [Text view]
1 <?php 2 3 /** 4 * @task config Configuring the Hook Engine 5 * @task hook Hook Execution 6 * @task git Git Hooks 7 * @task hg Mercurial Hooks 8 * @task svn Subversion Hooks 9 * @task internal Internals 10 */ 11 final class DiffusionCommitHookEngine extends Phobject { 12 13 const ENV_USER = 'PHABRICATOR_USER'; 14 const ENV_REMOTE_ADDRESS = 'PHABRICATOR_REMOTE_ADDRESS'; 15 const ENV_REMOTE_PROTOCOL = 'PHABRICATOR_REMOTE_PROTOCOL'; 16 17 const EMPTY_HASH = '0000000000000000000000000000000000000000'; 18 19 private $viewer; 20 private $repository; 21 private $stdin; 22 private $originalArgv; 23 private $subversionTransaction; 24 private $subversionRepository; 25 private $remoteAddress; 26 private $remoteProtocol; 27 private $transactionKey; 28 private $mercurialHook; 29 private $mercurialCommits = array(); 30 private $gitCommits = array(); 31 32 private $heraldViewerProjects; 33 private $rejectCode = PhabricatorRepositoryPushLog::REJECT_BROKEN; 34 private $rejectDetails; 35 private $emailPHIDs = array(); 36 37 38 /* -( Config )------------------------------------------------------------- */ 39 40 41 public function setRemoteProtocol($remote_protocol) { 42 $this->remoteProtocol = $remote_protocol; 43 return $this; 44 } 45 46 public function getRemoteProtocol() { 47 return $this->remoteProtocol; 48 } 49 50 public function setRemoteAddress($remote_address) { 51 $this->remoteAddress = $remote_address; 52 return $this; 53 } 54 55 public function getRemoteAddress() { 56 return $this->remoteAddress; 57 } 58 59 private function getRemoteAddressForLog() { 60 // If whatever we have here isn't a valid IPv4 address, just store `null`. 61 // Older versions of PHP return `-1` on failure instead of `false`. 62 $remote_address = $this->getRemoteAddress(); 63 $remote_address = max(0, ip2long($remote_address)); 64 $remote_address = nonempty($remote_address, null); 65 return $remote_address; 66 } 67 68 public function setSubversionTransactionInfo($transaction, $repository) { 69 $this->subversionTransaction = $transaction; 70 $this->subversionRepository = $repository; 71 return $this; 72 } 73 74 public function setStdin($stdin) { 75 $this->stdin = $stdin; 76 return $this; 77 } 78 79 public function getStdin() { 80 return $this->stdin; 81 } 82 83 public function setOriginalArgv(array $original_argv) { 84 $this->originalArgv = $original_argv; 85 return $this; 86 } 87 88 public function getOriginalArgv() { 89 return $this->originalArgv; 90 } 91 92 public function setRepository(PhabricatorRepository $repository) { 93 $this->repository = $repository; 94 return $this; 95 } 96 97 public function getRepository() { 98 return $this->repository; 99 } 100 101 public function setViewer(PhabricatorUser $viewer) { 102 $this->viewer = $viewer; 103 return $this; 104 } 105 106 public function getViewer() { 107 return $this->viewer; 108 } 109 110 public function setMercurialHook($mercurial_hook) { 111 $this->mercurialHook = $mercurial_hook; 112 return $this; 113 } 114 115 public function getMercurialHook() { 116 return $this->mercurialHook; 117 } 118 119 120 /* -( Hook Execution )----------------------------------------------------- */ 121 122 123 public function execute() { 124 $ref_updates = $this->findRefUpdates(); 125 $all_updates = $ref_updates; 126 127 $caught = null; 128 try { 129 130 try { 131 $this->rejectDangerousChanges($ref_updates); 132 } catch (DiffusionCommitHookRejectException $ex) { 133 // If we're rejecting dangerous changes, flag everything that we've 134 // seen as rejected so it's clear that none of it was accepted. 135 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_DANGEROUS; 136 throw $ex; 137 } 138 139 $this->applyHeraldRefRules($ref_updates, $all_updates); 140 141 $content_updates = $this->findContentUpdates($ref_updates); 142 $all_updates = array_merge($all_updates, $content_updates); 143 144 $this->applyHeraldContentRules($content_updates, $all_updates); 145 146 // Run custom scripts in `hook.d/` directories. 147 $this->applyCustomHooks($all_updates); 148 149 // If we make it this far, we're accepting these changes. Mark all the 150 // logs as accepted. 151 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_ACCEPT; 152 } catch (Exception $ex) { 153 // We'll throw this again in a minute, but we want to save all the logs 154 // first. 155 $caught = $ex; 156 } 157 158 // Save all the logs no matter what the outcome was. 159 $event = $this->newPushEvent(); 160 161 $event->setRejectCode($this->rejectCode); 162 $event->setRejectDetails($this->rejectDetails); 163 164 $event->openTransaction(); 165 $event->save(); 166 foreach ($all_updates as $update) { 167 $update->setPushEventPHID($event->getPHID()); 168 $update->save(); 169 } 170 $event->saveTransaction(); 171 172 if ($caught) { 173 throw $caught; 174 } 175 176 if ($this->emailPHIDs) { 177 // If Herald rules triggered email to users, queue a worker to send the 178 // mail. We do this out-of-process so that we block pushes as briefly 179 // as possible. 180 181 // (We do need to pull some commit info here because the commit objects 182 // may not exist yet when this worker runs, which could be immediately.) 183 184 PhabricatorWorker::scheduleTask( 185 'PhabricatorRepositoryPushMailWorker', 186 array( 187 'eventPHID' => $event->getPHID(), 188 'emailPHIDs' => array_values($this->emailPHIDs), 189 'info' => $this->loadCommitInfoForWorker($all_updates), 190 ), 191 PhabricatorWorker::PRIORITY_ALERTS); 192 } 193 194 return 0; 195 } 196 197 private function findRefUpdates() { 198 $type = $this->getRepository()->getVersionControlSystem(); 199 switch ($type) { 200 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 201 return $this->findGitRefUpdates(); 202 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 203 return $this->findMercurialRefUpdates(); 204 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: 205 return $this->findSubversionRefUpdates(); 206 default: 207 throw new Exception(pht('Unsupported repository type "%s"!', $type)); 208 } 209 } 210 211 private function rejectDangerousChanges(array $ref_updates) { 212 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog'); 213 214 $repository = $this->getRepository(); 215 if ($repository->shouldAllowDangerousChanges()) { 216 return; 217 } 218 219 $flag_dangerous = PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS; 220 221 foreach ($ref_updates as $ref_update) { 222 if (!$ref_update->hasChangeFlags($flag_dangerous)) { 223 // This is not a dangerous change. 224 continue; 225 } 226 227 // We either have a branch deletion or a non fast-forward branch update. 228 // Format a message and reject the push. 229 230 $message = pht( 231 "DANGEROUS CHANGE: %s\n". 232 "Dangerous change protection is enabled for this repository.\n". 233 "Edit the repository configuration before making dangerous changes.", 234 $ref_update->getDangerousChangeDescription()); 235 236 throw new DiffusionCommitHookRejectException($message); 237 } 238 } 239 240 private function findContentUpdates(array $ref_updates) { 241 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog'); 242 243 $type = $this->getRepository()->getVersionControlSystem(); 244 switch ($type) { 245 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 246 return $this->findGitContentUpdates($ref_updates); 247 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 248 return $this->findMercurialContentUpdates($ref_updates); 249 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: 250 return $this->findSubversionContentUpdates($ref_updates); 251 default: 252 throw new Exception(pht('Unsupported repository type "%s"!', $type)); 253 } 254 } 255 256 257 /* -( Herald )------------------------------------------------------------- */ 258 259 private function applyHeraldRefRules( 260 array $ref_updates, 261 array $all_updates) { 262 $this->applyHeraldRules( 263 $ref_updates, 264 new HeraldPreCommitRefAdapter(), 265 $all_updates); 266 } 267 268 private function applyHeraldContentRules( 269 array $content_updates, 270 array $all_updates) { 271 $this->applyHeraldRules( 272 $content_updates, 273 new HeraldPreCommitContentAdapter(), 274 $all_updates); 275 } 276 277 private function applyHeraldRules( 278 array $updates, 279 HeraldAdapter $adapter_template, 280 array $all_updates) { 281 282 if (!$updates) { 283 return; 284 } 285 286 $adapter_template->setHookEngine($this); 287 288 $engine = new HeraldEngine(); 289 $rules = null; 290 $blocking_effect = null; 291 $blocked_update = null; 292 foreach ($updates as $update) { 293 $adapter = id(clone $adapter_template) 294 ->setPushLog($update); 295 296 if ($rules === null) { 297 $rules = $engine->loadRulesForAdapter($adapter); 298 } 299 300 $effects = $engine->applyRules($rules, $adapter); 301 $engine->applyEffects($effects, $adapter, $rules); 302 $xscript = $engine->getTranscript(); 303 304 // Store any PHIDs we want to send email to for later. 305 foreach ($adapter->getEmailPHIDs() as $email_phid) { 306 $this->emailPHIDs[$email_phid] = $email_phid; 307 } 308 309 if ($blocking_effect === null) { 310 foreach ($effects as $effect) { 311 if ($effect->getAction() == HeraldAdapter::ACTION_BLOCK) { 312 $blocking_effect = $effect; 313 $blocked_update = $update; 314 break; 315 } 316 } 317 } 318 } 319 320 if ($blocking_effect) { 321 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_HERALD; 322 $this->rejectDetails = $blocking_effect->getRulePHID(); 323 324 $message = $blocking_effect->getTarget(); 325 if (!strlen($message)) { 326 $message = pht('(None.)'); 327 } 328 329 $rules = mpull($rules, null, 'getID'); 330 $rule = idx($rules, $effect->getRuleID()); 331 if ($rule && strlen($rule->getName())) { 332 $rule_name = $rule->getName(); 333 } else { 334 $rule_name = pht('Unnamed Herald Rule'); 335 } 336 337 $blocked_ref_name = coalesce( 338 $blocked_update->getRefName(), 339 $blocked_update->getRefNewShort()); 340 $blocked_name = $blocked_update->getRefType().'/'.$blocked_ref_name; 341 342 throw new DiffusionCommitHookRejectException( 343 pht( 344 "This push was rejected by Herald push rule %s.\n". 345 "Change: %s\n". 346 " Rule: %s\n". 347 "Reason: %s", 348 'H'.$blocking_effect->getRuleID(), 349 $blocked_name, 350 $rule_name, 351 $message)); 352 } 353 } 354 355 public function loadViewerProjectPHIDsForHerald() { 356 // This just caches the viewer's projects so we don't need to load them 357 // over and over again when applying Herald rules. 358 if ($this->heraldViewerProjects === null) { 359 $this->heraldViewerProjects = id(new PhabricatorProjectQuery()) 360 ->setViewer($this->getViewer()) 361 ->withMemberPHIDs(array($this->getViewer()->getPHID())) 362 ->execute(); 363 } 364 365 return mpull($this->heraldViewerProjects, 'getPHID'); 366 } 367 368 369 /* -( Git )---------------------------------------------------------------- */ 370 371 372 private function findGitRefUpdates() { 373 $ref_updates = array(); 374 375 // First, parse stdin, which lists all the ref changes. The input looks 376 // like this: 377 // 378 // <old hash> <new hash> <ref> 379 380 $stdin = $this->getStdin(); 381 $lines = phutil_split_lines($stdin, $retain_endings = false); 382 foreach ($lines as $line) { 383 $parts = explode(' ', $line, 3); 384 if (count($parts) != 3) { 385 throw new Exception(pht('Expected "old new ref", got "%s".', $line)); 386 } 387 388 $ref_old = $parts[0]; 389 $ref_new = $parts[1]; 390 $ref_raw = $parts[2]; 391 392 if (preg_match('(^refs/heads/)', $ref_raw)) { 393 $ref_type = PhabricatorRepositoryPushLog::REFTYPE_BRANCH; 394 $ref_raw = substr($ref_raw, strlen('refs/heads/')); 395 } else if (preg_match('(^refs/tags/)', $ref_raw)) { 396 $ref_type = PhabricatorRepositoryPushLog::REFTYPE_TAG; 397 $ref_raw = substr($ref_raw, strlen('refs/tags/')); 398 } else { 399 throw new Exception( 400 pht( 401 "Unable to identify the reftype of '%s'. Rejecting push.", 402 $ref_raw)); 403 } 404 405 $ref_update = $this->newPushLog() 406 ->setRefType($ref_type) 407 ->setRefName($ref_raw) 408 ->setRefOld($ref_old) 409 ->setRefNew($ref_new); 410 411 $ref_updates[] = $ref_update; 412 } 413 414 $this->findGitMergeBases($ref_updates); 415 $this->findGitChangeFlags($ref_updates); 416 417 return $ref_updates; 418 } 419 420 421 private function findGitMergeBases(array $ref_updates) { 422 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog'); 423 424 $futures = array(); 425 foreach ($ref_updates as $key => $ref_update) { 426 // If the old hash is "00000...", the ref is being created (either a new 427 // branch, or a new tag). If the new hash is "00000...", the ref is being 428 // deleted. If both are nonempty, the ref is being updated. For updates, 429 // we'll figure out the `merge-base` of the old and new objects here. This 430 // lets us reject non-FF changes cheaply; later, we'll figure out exactly 431 // which commits are new. 432 $ref_old = $ref_update->getRefOld(); 433 $ref_new = $ref_update->getRefNew(); 434 435 if (($ref_old === self::EMPTY_HASH) || 436 ($ref_new === self::EMPTY_HASH)) { 437 continue; 438 } 439 440 $futures[$key] = $this->getRepository()->getLocalCommandFuture( 441 'merge-base %s %s', 442 $ref_old, 443 $ref_new); 444 } 445 446 foreach (Futures($futures)->limit(8) as $key => $future) { 447 448 // If 'old' and 'new' have no common ancestors (for example, a force push 449 // which completely rewrites a ref), `git merge-base` will exit with 450 // an error and no output. It would be nice to find a positive test 451 // for this instead, but I couldn't immediately come up with one. See 452 // T4224. Assume this means there are no ancestors. 453 454 list($err, $stdout) = $future->resolve(); 455 456 if ($err) { 457 $merge_base = null; 458 } else { 459 $merge_base = rtrim($stdout, "\n"); 460 } 461 462 $ref_update = $ref_updates[$key]; 463 $ref_update->setMergeBase($merge_base); 464 } 465 466 return $ref_updates; 467 } 468 469 470 private function findGitChangeFlags(array $ref_updates) { 471 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog'); 472 473 foreach ($ref_updates as $key => $ref_update) { 474 $ref_old = $ref_update->getRefOld(); 475 $ref_new = $ref_update->getRefNew(); 476 $ref_type = $ref_update->getRefType(); 477 478 $ref_flags = 0; 479 $dangerous = null; 480 481 if (($ref_old === self::EMPTY_HASH) && ($ref_new === self::EMPTY_HASH)) { 482 // This happens if you try to delete a tag or branch which does not 483 // exist by pushing directly to the ref. Git will warn about it but 484 // allow it. Just call it a delete, without flagging it as dangerous. 485 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; 486 } else if ($ref_old === self::EMPTY_HASH) { 487 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; 488 } else if ($ref_new === self::EMPTY_HASH) { 489 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; 490 if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) { 491 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS; 492 $dangerous = pht( 493 "The change you're attempting to push deletes the branch '%s'.", 494 $ref_update->getRefName()); 495 } 496 } else { 497 $merge_base = $ref_update->getMergeBase(); 498 if ($merge_base == $ref_old) { 499 // This is a fast-forward update to an existing branch. 500 // These are safe. 501 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; 502 } else { 503 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE; 504 505 // For now, we don't consider deleting or moving tags to be a 506 // "dangerous" update. It's way harder to get wrong and should be easy 507 // to recover from once we have better logging. Only add the dangerous 508 // flag if this ref is a branch. 509 510 if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) { 511 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS; 512 513 $dangerous = pht( 514 "The change you're attempting to push updates the branch '%s' ". 515 "from '%s' to '%s', but this is not a fast-forward. Pushes ". 516 "which rewrite published branch history are dangerous.", 517 $ref_update->getRefName(), 518 $ref_update->getRefOldShort(), 519 $ref_update->getRefNewShort()); 520 } 521 } 522 } 523 524 $ref_update->setChangeFlags($ref_flags); 525 if ($dangerous !== null) { 526 $ref_update->attachDangerousChangeDescription($dangerous); 527 } 528 } 529 530 return $ref_updates; 531 } 532 533 534 private function findGitContentUpdates(array $ref_updates) { 535 $flag_delete = PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; 536 537 $futures = array(); 538 foreach ($ref_updates as $key => $ref_update) { 539 if ($ref_update->hasChangeFlags($flag_delete)) { 540 // Deleting a branch or tag can never create any new commits. 541 continue; 542 } 543 544 // NOTE: This piece of magic finds all new commits, by walking backward 545 // from the new value to the value of *any* existing ref in the 546 // repository. Particularly, this will cover the cases of a new branch, a 547 // completely moved tag, etc. 548 $futures[$key] = $this->getRepository()->getLocalCommandFuture( 549 'log --format=%s %s --not --all', 550 '%H', 551 $ref_update->getRefNew()); 552 } 553 554 $content_updates = array(); 555 foreach (Futures($futures)->limit(8) as $key => $future) { 556 list($stdout) = $future->resolvex(); 557 558 if (!strlen(trim($stdout))) { 559 // This change doesn't have any new commits. One common case of this 560 // is creating a new tag which points at an existing commit. 561 continue; 562 } 563 564 $commits = phutil_split_lines($stdout, $retain_newlines = false); 565 566 // If we're looking at a branch, mark all of the new commits as on that 567 // branch. It's only possible for these commits to be on updated branches, 568 // since any other branch heads are necessarily behind them. 569 $branch_name = null; 570 $ref_update = $ref_updates[$key]; 571 $type_branch = PhabricatorRepositoryPushLog::REFTYPE_BRANCH; 572 if ($ref_update->getRefType() == $type_branch) { 573 $branch_name = $ref_update->getRefName(); 574 } 575 576 foreach ($commits as $commit) { 577 if ($branch_name) { 578 $this->gitCommits[$commit][] = $branch_name; 579 } 580 $content_updates[$commit] = $this->newPushLog() 581 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT) 582 ->setRefNew($commit) 583 ->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD); 584 } 585 } 586 587 return $content_updates; 588 } 589 590 /* -( Custom )------------------------------------------------------------- */ 591 592 private function applyCustomHooks(array $updates) { 593 $args = $this->getOriginalArgv(); 594 $stdin = $this->getStdin(); 595 $console = PhutilConsole::getConsole(); 596 597 $env = array( 598 'PHABRICATOR_REPOSITORY' => $this->getRepository()->getCallsign(), 599 self::ENV_USER => $this->getViewer()->getUsername(), 600 self::ENV_REMOTE_PROTOCOL => $this->getRemoteProtocol(), 601 self::ENV_REMOTE_ADDRESS => $this->getRemoteAddress(), 602 ); 603 604 $directories = $this->getRepository()->getHookDirectories(); 605 foreach ($directories as $directory) { 606 $hooks = $this->getExecutablesInDirectory($directory); 607 sort($hooks); 608 foreach ($hooks as $hook) { 609 // NOTE: We're explicitly running the hooks in sequential order to 610 // make this more predictable. 611 $future = id(new ExecFuture('%s %Ls', $hook, $args)) 612 ->setEnv($env, $wipe_process_env = false) 613 ->write($stdin); 614 615 list($err, $stdout, $stderr) = $future->resolve(); 616 if (!$err) { 617 // This hook ran OK, but echo its output in case there was something 618 // informative. 619 $console->writeOut('%s', $stdout); 620 $console->writeErr('%s', $stderr); 621 continue; 622 } 623 624 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_EXTERNAL; 625 $this->rejectDetails = basename($hook); 626 627 throw new DiffusionCommitHookRejectException( 628 pht( 629 "This push was rejected by custom hook script '%s':\n\n%s%s", 630 basename($hook), 631 $stdout, 632 $stderr)); 633 } 634 } 635 } 636 637 private function getExecutablesInDirectory($directory) { 638 $executables = array(); 639 640 if (!Filesystem::pathExists($directory)) { 641 return $executables; 642 } 643 644 foreach (Filesystem::listDirectory($directory) as $path) { 645 $full_path = $directory.DIRECTORY_SEPARATOR.$path; 646 if (!is_executable($full_path)) { 647 // Don't include non-executable files. 648 continue; 649 } 650 651 if (basename($full_path) == 'README') { 652 // Don't include README, even if it is marked as executable. It almost 653 // certainly got caught in the crossfire of a sweeping `chmod`, since 654 // users do this with some frequency. 655 continue; 656 } 657 658 $executables[] = $full_path; 659 } 660 661 return $executables; 662 } 663 664 665 /* -( Mercurial )---------------------------------------------------------- */ 666 667 668 private function findMercurialRefUpdates() { 669 $hook = $this->getMercurialHook(); 670 switch ($hook) { 671 case 'pretxnchangegroup': 672 return $this->findMercurialChangegroupRefUpdates(); 673 case 'prepushkey': 674 return $this->findMercurialPushKeyRefUpdates(); 675 default: 676 throw new Exception(pht('Unrecognized hook "%s"!', $hook)); 677 } 678 } 679 680 private function findMercurialChangegroupRefUpdates() { 681 $hg_node = getenv('HG_NODE'); 682 if (!$hg_node) { 683 throw new Exception(pht('Expected HG_NODE in environment!')); 684 } 685 686 // NOTE: We need to make sure this is passed to subprocesses, or they won't 687 // be able to see new commits. Mercurial uses this as a marker to determine 688 // whether the pending changes are visible or not. 689 $_ENV['HG_PENDING'] = getenv('HG_PENDING'); 690 $repository = $this->getRepository(); 691 692 $futures = array(); 693 694 foreach (array('old', 'new') as $key) { 695 $futures[$key] = $repository->getLocalCommandFuture( 696 'heads --template %s', 697 '{node}\1{branch}\2'); 698 } 699 // Wipe HG_PENDING out of the old environment so we see the pre-commit 700 // state of the repository. 701 $futures['old']->updateEnv('HG_PENDING', null); 702 703 $futures['commits'] = $repository->getLocalCommandFuture( 704 'log --rev %s --template %s', 705 hgsprintf('%s:%s', $hg_node, 'tip'), 706 '{node}\1{branch}\2'); 707 708 // Resolve all of the futures now. We don't need the 'commits' future yet, 709 // but it simplifies the logic to just get it out of the way. 710 foreach (Futures($futures) as $future) { 711 $future->resolve(); 712 } 713 714 list($commit_raw) = $futures['commits']->resolvex(); 715 $commit_map = $this->parseMercurialCommits($commit_raw); 716 $this->mercurialCommits = $commit_map; 717 718 // NOTE: `hg heads` exits with an error code and no output if the repository 719 // has no heads. Most commonly this happens on a new repository. We know 720 // we can run `hg` successfully since the `hg log` above didn't error, so 721 // just ignore the error code. 722 723 list($err, $old_raw) = $futures['old']->resolve(); 724 $old_refs = $this->parseMercurialHeads($old_raw); 725 726 list($err, $new_raw) = $futures['new']->resolve(); 727 $new_refs = $this->parseMercurialHeads($new_raw); 728 729 $all_refs = array_keys($old_refs + $new_refs); 730 731 $ref_updates = array(); 732 foreach ($all_refs as $ref) { 733 $old_heads = idx($old_refs, $ref, array()); 734 $new_heads = idx($new_refs, $ref, array()); 735 736 sort($old_heads); 737 sort($new_heads); 738 739 if (!$old_heads && !$new_heads) { 740 // This should never be possible, as it makes no sense. Explode. 741 throw new Exception( 742 pht( 743 'Mercurial repository has no new or old heads for branch "%s" '. 744 'after push. This makes no sense; rejecting change.', 745 $ref)); 746 } 747 748 if ($old_heads === $new_heads) { 749 // No changes to this branch, so skip it. 750 continue; 751 } 752 753 $stray_heads = array(); 754 755 if ($old_heads && !$new_heads) { 756 // This is a branch deletion with "--close-branch". 757 $head_map = array(); 758 foreach ($old_heads as $old_head) { 759 $head_map[$old_head] = array(self::EMPTY_HASH); 760 } 761 } else if (count($old_heads) > 1) { 762 // HORRIBLE: In Mercurial, branches can have multiple heads. If the 763 // old branch had multiple heads, we need to figure out which new 764 // heads descend from which old heads, so we can tell whether you're 765 // actively creating new heads (dangerous) or just working in a 766 // repository that's already full of garbage (strongly discouraged but 767 // not as inherently dangerous). These cases should be very uncommon. 768 769 // NOTE: We're only looking for heads on the same branch. The old 770 // tip of the branch may be the branchpoint for other branches, but that 771 // is OK. 772 773 $dfutures = array(); 774 foreach ($old_heads as $old_head) { 775 $dfutures[$old_head] = $repository->getLocalCommandFuture( 776 'log --branch %s --rev %s --template %s', 777 $ref, 778 hgsprintf('(descendants(%s) and head())', $old_head), 779 '{node}\1'); 780 } 781 782 $head_map = array(); 783 foreach (Futures($dfutures) as $future_head => $dfuture) { 784 list($stdout) = $dfuture->resolvex(); 785 $descendant_heads = array_filter(explode("\1", $stdout)); 786 if ($descendant_heads) { 787 // This old head has at least one descendant in the push. 788 $head_map[$future_head] = $descendant_heads; 789 } else { 790 // This old head has no descendants, so it is being deleted. 791 $head_map[$future_head] = array(self::EMPTY_HASH); 792 } 793 } 794 795 // Now, find all the new stray heads this push creates, if any. These 796 // are new heads which do not descend from the old heads. 797 $seen = array_fuse(array_mergev($head_map)); 798 foreach ($new_heads as $new_head) { 799 if ($new_head === self::EMPTY_HASH) { 800 // If a branch head is being deleted, don't insert it as an add. 801 continue; 802 } 803 if (empty($seen[$new_head])) { 804 $head_map[self::EMPTY_HASH][] = $new_head; 805 } 806 } 807 } else if ($old_heads) { 808 $head_map[head($old_heads)] = $new_heads; 809 } else { 810 $head_map[self::EMPTY_HASH] = $new_heads; 811 } 812 813 foreach ($head_map as $old_head => $child_heads) { 814 foreach ($child_heads as $new_head) { 815 if ($new_head === $old_head) { 816 continue; 817 } 818 819 $ref_flags = 0; 820 $dangerous = null; 821 if ($old_head == self::EMPTY_HASH) { 822 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; 823 } else { 824 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; 825 } 826 827 828 $deletes_existing_head = ($new_head == self::EMPTY_HASH); 829 $splits_existing_head = (count($child_heads) > 1); 830 $creates_duplicate_head = ($old_head == self::EMPTY_HASH) && 831 (count($head_map) > 1); 832 833 if ($splits_existing_head || $creates_duplicate_head) { 834 $readable_child_heads = array(); 835 foreach ($child_heads as $child_head) { 836 $readable_child_heads[] = substr($child_head, 0, 12); 837 } 838 839 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS; 840 841 if ($splits_existing_head) { 842 // We're splitting an existing head into two or more heads. 843 // This is dangerous, and a super bad idea. Note that we're only 844 // raising this if you're actively splitting a branch head. If a 845 // head split in the past, we don't consider appends to it 846 // to be dangerous. 847 $dangerous = pht( 848 "The change you're attempting to push splits the head of ". 849 "branch '%s' into multiple heads: %s. This is inadvisable ". 850 "and dangerous.", 851 $ref, 852 implode(', ', $readable_child_heads)); 853 } else { 854 // We're adding a second (or more) head to a branch. The new 855 // head is not a descendant of any old head. 856 $dangerous = pht( 857 "The change you're attempting to push creates new, divergent ". 858 "heads for the branch '%s': %s. This is inadvisable and ". 859 "dangerous.", 860 $ref, 861 implode(', ', $readable_child_heads)); 862 } 863 } 864 865 if ($deletes_existing_head) { 866 // TODO: Somewhere in here we should be setting CHANGEFLAG_REWRITE 867 // if we are also creating at least one other head to replace 868 // this one. 869 870 // NOTE: In Git, this is a dangerous change, but it is not dangerous 871 // in Mercurial. Mercurial branches are version controlled, and 872 // Mercurial does not prompt you for any special flags when pushing 873 // a `--close-branch` commit by default. 874 875 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; 876 } 877 878 $ref_update = $this->newPushLog() 879 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BRANCH) 880 ->setRefName($ref) 881 ->setRefOld($old_head) 882 ->setRefNew($new_head) 883 ->setChangeFlags($ref_flags); 884 885 if ($dangerous !== null) { 886 $ref_update->attachDangerousChangeDescription($dangerous); 887 } 888 889 $ref_updates[] = $ref_update; 890 } 891 } 892 } 893 894 return $ref_updates; 895 } 896 897 private function findMercurialPushKeyRefUpdates() { 898 $key_namespace = getenv('HG_NAMESPACE'); 899 900 if ($key_namespace === 'phases') { 901 // Mercurial changes commit phases as part of normal push operations. We 902 // just ignore these, as they don't seem to represent anything 903 // interesting. 904 return array(); 905 } 906 907 $key_name = getenv('HG_KEY'); 908 909 $key_old = getenv('HG_OLD'); 910 if (!strlen($key_old)) { 911 $key_old = null; 912 } 913 914 $key_new = getenv('HG_NEW'); 915 if (!strlen($key_new)) { 916 $key_new = null; 917 } 918 919 if ($key_namespace !== 'bookmarks') { 920 throw new Exception( 921 pht( 922 "Unknown Mercurial key namespace '%s', with key '%s' (%s -> %s). ". 923 "Rejecting push.", 924 $key_namespace, 925 $key_name, 926 coalesce($key_old, pht('null')), 927 coalesce($key_new, pht('null')))); 928 } 929 930 if ($key_old === $key_new) { 931 // We get a callback when the bookmark doesn't change. Just ignore this, 932 // as it's a no-op. 933 return array(); 934 } 935 936 $ref_flags = 0; 937 $merge_base = null; 938 if ($key_old === null) { 939 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; 940 } else if ($key_new === null) { 941 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; 942 } else { 943 list($merge_base_raw) = $this->getRepository()->execxLocalCommand( 944 'log --template %s --rev %s', 945 '{node}', 946 hgsprintf('ancestor(%s, %s)', $key_old, $key_new)); 947 948 if (strlen(trim($merge_base_raw))) { 949 $merge_base = trim($merge_base_raw); 950 } 951 952 if ($merge_base && ($merge_base === $key_old)) { 953 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; 954 } else { 955 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE; 956 } 957 } 958 959 $ref_update = $this->newPushLog() 960 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BOOKMARK) 961 ->setRefName($key_name) 962 ->setRefOld(coalesce($key_old, self::EMPTY_HASH)) 963 ->setRefNew(coalesce($key_new, self::EMPTY_HASH)) 964 ->setChangeFlags($ref_flags); 965 966 return array($ref_update); 967 } 968 969 private function findMercurialContentUpdates(array $ref_updates) { 970 $content_updates = array(); 971 972 foreach ($this->mercurialCommits as $commit => $branches) { 973 $content_updates[$commit] = $this->newPushLog() 974 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT) 975 ->setRefNew($commit) 976 ->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD); 977 } 978 979 return $content_updates; 980 } 981 982 private function parseMercurialCommits($raw) { 983 $commits_lines = explode("\2", $raw); 984 $commits_lines = array_filter($commits_lines); 985 $commit_map = array(); 986 foreach ($commits_lines as $commit_line) { 987 list($node, $branch) = explode("\1", $commit_line); 988 $commit_map[$node] = array($branch); 989 } 990 991 return $commit_map; 992 } 993 994 private function parseMercurialHeads($raw) { 995 $heads_map = $this->parseMercurialCommits($raw); 996 997 $heads = array(); 998 foreach ($heads_map as $commit => $branches) { 999 foreach ($branches as $branch) { 1000 $heads[$branch][] = $commit; 1001 } 1002 } 1003 1004 return $heads; 1005 } 1006 1007 1008 /* -( Subversion )--------------------------------------------------------- */ 1009 1010 1011 private function findSubversionRefUpdates() { 1012 // Subversion doesn't have any kind of mutable ref metadata. 1013 return array(); 1014 } 1015 1016 private function findSubversionContentUpdates(array $ref_updates) { 1017 list($youngest) = execx( 1018 'svnlook youngest %s', 1019 $this->subversionRepository); 1020 $ref_new = (int)$youngest + 1; 1021 1022 $ref_flags = 0; 1023 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; 1024 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; 1025 1026 $ref_content = $this->newPushLog() 1027 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT) 1028 ->setRefNew($ref_new) 1029 ->setChangeFlags($ref_flags); 1030 1031 return array($ref_content); 1032 } 1033 1034 1035 /* -( Internals )---------------------------------------------------------- */ 1036 1037 1038 private function newPushLog() { 1039 // NOTE: We generate PHIDs up front so the Herald transcripts can pick them 1040 // up. 1041 $phid = id(new PhabricatorRepositoryPushLog())->generatePHID(); 1042 1043 return PhabricatorRepositoryPushLog::initializeNewLog($this->getViewer()) 1044 ->setPHID($phid) 1045 ->setRepositoryPHID($this->getRepository()->getPHID()) 1046 ->attachRepository($this->getRepository()) 1047 ->setEpoch(time()); 1048 } 1049 1050 private function newPushEvent() { 1051 $viewer = $this->getViewer(); 1052 return PhabricatorRepositoryPushEvent::initializeNewEvent($viewer) 1053 ->setRepositoryPHID($this->getRepository()->getPHID()) 1054 ->setRemoteAddress($this->getRemoteAddressForLog()) 1055 ->setRemoteProtocol($this->getRemoteProtocol()) 1056 ->setEpoch(time()); 1057 } 1058 1059 public function loadChangesetsForCommit($identifier) { 1060 $byte_limit = HeraldCommitAdapter::getEnormousByteLimit(); 1061 $time_limit = HeraldCommitAdapter::getEnormousTimeLimit(); 1062 1063 $vcs = $this->getRepository()->getVersionControlSystem(); 1064 switch ($vcs) { 1065 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 1066 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 1067 // For git and hg, we can use normal commands. 1068 $drequest = DiffusionRequest::newFromDictionary( 1069 array( 1070 'repository' => $this->getRepository(), 1071 'user' => $this->getViewer(), 1072 'commit' => $identifier, 1073 )); 1074 1075 $raw_diff = DiffusionRawDiffQuery::newFromDiffusionRequest($drequest) 1076 ->setTimeout($time_limit) 1077 ->setByteLimit($byte_limit) 1078 ->setLinesOfContext(0) 1079 ->loadRawDiff(); 1080 break; 1081 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: 1082 // TODO: This diff has 3 lines of context, which produces slightly 1083 // incorrect "added file content" and "removed file content" results. 1084 // This may also choke on binaries, but "svnlook diff" does not support 1085 // the "--diff-cmd" flag. 1086 1087 // For subversion, we need to use `svnlook`. 1088 $future = new ExecFuture( 1089 'svnlook diff -t %s %s', 1090 $this->subversionTransaction, 1091 $this->subversionRepository); 1092 1093 $future->setTimeout($time_limit); 1094 $future->setStdoutSizeLimit($byte_limit); 1095 $future->setStderrSizeLimit($byte_limit); 1096 1097 list($raw_diff) = $future->resolvex(); 1098 break; 1099 default: 1100 throw new Exception(pht("Unknown VCS '%s!'", $vcs)); 1101 } 1102 1103 if (strlen($raw_diff) >= $byte_limit) { 1104 throw new Exception( 1105 pht( 1106 'The raw text of this change is enormous (larger than %d '. 1107 'bytes). Herald can not process it.', 1108 $byte_limit)); 1109 } 1110 1111 if (!strlen($raw_diff)) { 1112 // If the commit is actually empty, just return no changesets. 1113 return array(); 1114 } 1115 1116 $parser = new ArcanistDiffParser(); 1117 $changes = $parser->parseDiff($raw_diff); 1118 $diff = DifferentialDiff::newFromRawChanges( 1119 $this->getViewer(), 1120 $changes); 1121 return $diff->getChangesets(); 1122 } 1123 1124 public function loadCommitRefForCommit($identifier) { 1125 $repository = $this->getRepository(); 1126 $vcs = $repository->getVersionControlSystem(); 1127 switch ($vcs) { 1128 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 1129 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 1130 return id(new DiffusionLowLevelCommitQuery()) 1131 ->setRepository($repository) 1132 ->withIdentifier($identifier) 1133 ->execute(); 1134 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: 1135 // For subversion, we need to use `svnlook`. 1136 list($message) = execx( 1137 'svnlook log -t %s %s', 1138 $this->subversionTransaction, 1139 $this->subversionRepository); 1140 1141 return id(new DiffusionCommitRef()) 1142 ->setMessage($message); 1143 break; 1144 default: 1145 throw new Exception(pht("Unknown VCS '%s!'", $vcs)); 1146 } 1147 } 1148 1149 public function loadBranches($identifier) { 1150 $repository = $this->getRepository(); 1151 $vcs = $repository->getVersionControlSystem(); 1152 switch ($vcs) { 1153 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: 1154 return idx($this->gitCommits, $identifier, array()); 1155 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: 1156 // NOTE: This will be "the branch the commit was made to", not 1157 // "a list of all branch heads which descend from the commit". 1158 // This is consistent with Mercurial, but possibly confusing. 1159 return idx($this->mercurialCommits, $identifier, array()); 1160 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: 1161 // Subversion doesn't have branches. 1162 return array(); 1163 } 1164 } 1165 1166 private function loadCommitInfoForWorker(array $all_updates) { 1167 $type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT; 1168 1169 $map = array(); 1170 foreach ($all_updates as $update) { 1171 if ($update->getRefType() != $type_commit) { 1172 continue; 1173 } 1174 $map[$update->getRefNew()] = array(); 1175 } 1176 1177 foreach ($map as $identifier => $info) { 1178 $ref = $this->loadCommitRefForCommit($identifier); 1179 $map[$identifier] += array( 1180 'summary' => $ref->getSummary(), 1181 'branches' => $this->loadBranches($identifier), 1182 ); 1183 } 1184 1185 return $map; 1186 } 1187 1188 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Sun Nov 30 09:20:46 2014 | Cross-referenced by PHPXref 0.7.1 |