[ Index ]

PHP Cross Reference of Phabricator

title

Body

[close]

/src/applications/diffusion/engine/ -> DiffusionCommitHookEngine.php (source)

   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  }


Generated: Sun Nov 30 09:20:46 2014 Cross-referenced by PHPXref 0.7.1