Remove product literal strings in "pht()", part 18
[phabricator.git] / src / applications / diffusion / engine / DiffusionCommitHookEngine.php
blobc172f27466d03035f541ded3d8771336a058fcb1
1 <?php
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
11 final class DiffusionCommitHookEngine extends Phobject {
13 const ENV_REPOSITORY = 'PHABRICATOR_REPOSITORY';
14 const ENV_USER = 'PHABRICATOR_USER';
15 const ENV_REQUEST = 'PHABRICATOR_REQUEST';
16 const ENV_REMOTE_ADDRESS = 'PHABRICATOR_REMOTE_ADDRESS';
17 const ENV_REMOTE_PROTOCOL = 'PHABRICATOR_REMOTE_PROTOCOL';
19 const EMPTY_HASH = '0000000000000000000000000000000000000000';
21 private $viewer;
22 private $repository;
23 private $stdin;
24 private $originalArgv;
25 private $subversionTransaction;
26 private $subversionRepository;
27 private $remoteAddress;
28 private $remoteProtocol;
29 private $requestIdentifier;
30 private $transactionKey;
31 private $mercurialHook;
32 private $mercurialCommits = array();
33 private $gitCommits = array();
34 private $startTime;
36 private $heraldViewerProjects;
37 private $rejectCode = PhabricatorRepositoryPushLog::REJECT_BROKEN;
38 private $rejectDetails;
39 private $emailPHIDs = array();
40 private $changesets = array();
41 private $changesetsSize = 0;
42 private $filesizeCache = array();
45 /* -( Config )------------------------------------------------------------- */
48 public function setRemoteProtocol($remote_protocol) {
49 $this->remoteProtocol = $remote_protocol;
50 return $this;
53 public function getRemoteProtocol() {
54 return $this->remoteProtocol;
57 public function setRemoteAddress($remote_address) {
58 $this->remoteAddress = $remote_address;
59 return $this;
62 public function getRemoteAddress() {
63 return $this->remoteAddress;
66 public function setRequestIdentifier($request_identifier) {
67 $this->requestIdentifier = $request_identifier;
68 return $this;
71 public function getRequestIdentifier() {
72 return $this->requestIdentifier;
75 public function setStartTime($start_time) {
76 $this->startTime = $start_time;
77 return $this;
80 public function getStartTime() {
81 return $this->startTime;
84 public function setSubversionTransactionInfo($transaction, $repository) {
85 $this->subversionTransaction = $transaction;
86 $this->subversionRepository = $repository;
87 return $this;
90 public function setStdin($stdin) {
91 $this->stdin = $stdin;
92 return $this;
95 public function getStdin() {
96 return $this->stdin;
99 public function setOriginalArgv(array $original_argv) {
100 $this->originalArgv = $original_argv;
101 return $this;
104 public function getOriginalArgv() {
105 return $this->originalArgv;
108 public function setRepository(PhabricatorRepository $repository) {
109 $this->repository = $repository;
110 return $this;
113 public function getRepository() {
114 return $this->repository;
117 public function setViewer(PhabricatorUser $viewer) {
118 $this->viewer = $viewer;
119 return $this;
122 public function getViewer() {
123 return $this->viewer;
126 public function setMercurialHook($mercurial_hook) {
127 $this->mercurialHook = $mercurial_hook;
128 return $this;
131 public function getMercurialHook() {
132 return $this->mercurialHook;
136 /* -( Hook Execution )----------------------------------------------------- */
139 public function execute() {
140 $ref_updates = $this->findRefUpdates();
141 $all_updates = $ref_updates;
143 $caught = null;
144 try {
146 try {
147 $this->rejectDangerousChanges($ref_updates);
148 } catch (DiffusionCommitHookRejectException $ex) {
149 // If we're rejecting dangerous changes, flag everything that we've
150 // seen as rejected so it's clear that none of it was accepted.
151 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_DANGEROUS;
152 throw $ex;
155 $content_updates = $this->findContentUpdates($ref_updates);
156 $all_updates = array_merge($ref_updates, $content_updates);
158 // If this is an "initial import" (a sizable push to a previously empty
159 // repository) we'll allow enormous changes and disable Herald rules.
160 // These rulesets can consume a large amount of time and memory and are
161 // generally not relevant when importing repository history.
162 $is_initial_import = $this->isInitialImport($all_updates);
164 if (!$is_initial_import) {
165 $this->applyHeraldRefRules($ref_updates);
168 try {
169 if (!$is_initial_import) {
170 $this->rejectOversizedFiles($content_updates);
172 } catch (DiffusionCommitHookRejectException $ex) {
173 // If we're rejecting oversized files, flag everything.
174 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_OVERSIZED;
175 throw $ex;
178 try {
179 if (!$is_initial_import) {
180 $this->rejectCommitsAffectingTooManyPaths($content_updates);
182 } catch (DiffusionCommitHookRejectException $ex) {
183 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_TOUCHES;
184 throw $ex;
187 try {
188 if (!$is_initial_import) {
189 $this->rejectEnormousChanges($content_updates);
191 } catch (DiffusionCommitHookRejectException $ex) {
192 // If we're rejecting enormous changes, flag everything.
193 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_ENORMOUS;
194 throw $ex;
197 if (!$is_initial_import) {
198 $this->applyHeraldContentRules($content_updates);
201 // Run custom scripts in `hook.d/` directories.
202 $this->applyCustomHooks($all_updates);
204 // If we make it this far, we're accepting these changes. Mark all the
205 // logs as accepted.
206 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_ACCEPT;
207 } catch (Exception $ex) {
208 // We'll throw this again in a minute, but we want to save all the logs
209 // first.
210 $caught = $ex;
213 // Save all the logs no matter what the outcome was.
214 $event = $this->newPushEvent();
216 $event->setRejectCode($this->rejectCode);
217 $event->setRejectDetails($this->rejectDetails);
219 $event->saveWithLogs($all_updates);
221 if ($caught) {
222 throw $caught;
225 // If this went through cleanly and was an import, set the importing flag
226 // on the repository. It will be cleared once we fully process everything.
228 if ($is_initial_import) {
229 $repository = $this->getRepository();
230 $repository->markImporting();
233 if ($this->emailPHIDs) {
234 // If Herald rules triggered email to users, queue a worker to send the
235 // mail. We do this out-of-process so that we block pushes as briefly
236 // as possible.
238 // (We do need to pull some commit info here because the commit objects
239 // may not exist yet when this worker runs, which could be immediately.)
241 PhabricatorWorker::scheduleTask(
242 'PhabricatorRepositoryPushMailWorker',
243 array(
244 'eventPHID' => $event->getPHID(),
245 'emailPHIDs' => array_values($this->emailPHIDs),
246 'info' => $this->loadCommitInfoForWorker($all_updates),
248 array(
249 'priority' => PhabricatorWorker::PRIORITY_ALERTS,
253 return 0;
256 private function findRefUpdates() {
257 $type = $this->getRepository()->getVersionControlSystem();
258 switch ($type) {
259 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
260 return $this->findGitRefUpdates();
261 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
262 return $this->findMercurialRefUpdates();
263 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
264 return $this->findSubversionRefUpdates();
265 default:
266 throw new Exception(pht('Unsupported repository type "%s"!', $type));
270 private function rejectDangerousChanges(array $ref_updates) {
271 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
273 $repository = $this->getRepository();
274 if ($repository->shouldAllowDangerousChanges()) {
275 return;
278 $flag_dangerous = PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;
280 foreach ($ref_updates as $ref_update) {
281 if (!$ref_update->hasChangeFlags($flag_dangerous)) {
282 // This is not a dangerous change.
283 continue;
286 // We either have a branch deletion or a non fast-forward branch update.
287 // Format a message and reject the push.
289 $message = pht(
290 "DANGEROUS CHANGE: %s\n".
291 "Dangerous change protection is enabled for this repository.\n".
292 "Edit the repository configuration before making dangerous changes.",
293 $ref_update->getDangerousChangeDescription());
295 throw new DiffusionCommitHookRejectException($message);
299 private function findContentUpdates(array $ref_updates) {
300 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
302 $type = $this->getRepository()->getVersionControlSystem();
303 switch ($type) {
304 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
305 return $this->findGitContentUpdates($ref_updates);
306 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
307 return $this->findMercurialContentUpdates($ref_updates);
308 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
309 return $this->findSubversionContentUpdates($ref_updates);
310 default:
311 throw new Exception(pht('Unsupported repository type "%s"!', $type));
316 /* -( Herald )------------------------------------------------------------- */
318 private function applyHeraldRefRules(array $ref_updates) {
319 $this->applyHeraldRules(
320 $ref_updates,
321 new HeraldPreCommitRefAdapter());
324 private function applyHeraldContentRules(array $content_updates) {
325 $this->applyHeraldRules(
326 $content_updates,
327 new HeraldPreCommitContentAdapter());
330 private function applyHeraldRules(
331 array $updates,
332 HeraldAdapter $adapter_template) {
334 if (!$updates) {
335 return;
338 $viewer = $this->getViewer();
340 $adapter_template
341 ->setHookEngine($this)
342 ->setActingAsPHID($viewer->getPHID());
344 $engine = new HeraldEngine();
345 $rules = null;
346 $blocking_effect = null;
347 $blocked_update = null;
348 $blocking_xscript = null;
349 foreach ($updates as $update) {
350 $adapter = id(clone $adapter_template)
351 ->setPushLog($update);
353 if ($rules === null) {
354 $rules = $engine->loadRulesForAdapter($adapter);
357 $effects = $engine->applyRules($rules, $adapter);
358 $engine->applyEffects($effects, $adapter, $rules);
359 $xscript = $engine->getTranscript();
361 // Store any PHIDs we want to send email to for later.
362 foreach ($adapter->getEmailPHIDs() as $email_phid) {
363 $this->emailPHIDs[$email_phid] = $email_phid;
366 $block_action = DiffusionBlockHeraldAction::ACTIONCONST;
368 if ($blocking_effect === null) {
369 foreach ($effects as $effect) {
370 if ($effect->getAction() == $block_action) {
371 $blocking_effect = $effect;
372 $blocked_update = $update;
373 $blocking_xscript = $xscript;
374 break;
380 if ($blocking_effect) {
381 $rule = $blocking_effect->getRule();
383 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_HERALD;
384 $this->rejectDetails = $rule->getPHID();
386 $message = $blocking_effect->getTarget();
387 if (!strlen($message)) {
388 $message = pht('(None.)');
391 $blocked_ref_name = coalesce(
392 $blocked_update->getRefName(),
393 $blocked_update->getRefNewShort());
394 $blocked_name = $blocked_update->getRefType().'/'.$blocked_ref_name;
396 throw new DiffusionCommitHookRejectException(
397 pht(
398 "This push was rejected by Herald push rule %s.\n".
399 " Change: %s\n".
400 " Rule: %s\n".
401 " Reason: %s\n".
402 "Transcript: %s",
403 $rule->getMonogram(),
404 $blocked_name,
405 $rule->getName(),
406 $message,
407 PhabricatorEnv::getProductionURI(
408 '/herald/transcript/'.$blocking_xscript->getID().'/')));
412 public function loadViewerProjectPHIDsForHerald() {
413 // This just caches the viewer's projects so we don't need to load them
414 // over and over again when applying Herald rules.
415 if ($this->heraldViewerProjects === null) {
416 $this->heraldViewerProjects = id(new PhabricatorProjectQuery())
417 ->setViewer($this->getViewer())
418 ->withMemberPHIDs(array($this->getViewer()->getPHID()))
419 ->execute();
422 return mpull($this->heraldViewerProjects, 'getPHID');
426 /* -( Git )---------------------------------------------------------------- */
429 private function findGitRefUpdates() {
430 $ref_updates = array();
432 // First, parse stdin, which lists all the ref changes. The input looks
433 // like this:
435 // <old hash> <new hash> <ref>
437 $stdin = $this->getStdin();
438 $lines = phutil_split_lines($stdin, $retain_endings = false);
439 foreach ($lines as $line) {
440 $parts = explode(' ', $line, 3);
441 if (count($parts) != 3) {
442 throw new Exception(pht('Expected "old new ref", got "%s".', $line));
445 $ref_old = $parts[0];
446 $ref_new = $parts[1];
447 $ref_raw = $parts[2];
449 if (preg_match('(^refs/heads/)', $ref_raw)) {
450 $ref_type = PhabricatorRepositoryPushLog::REFTYPE_BRANCH;
451 $ref_raw = substr($ref_raw, strlen('refs/heads/'));
452 } else if (preg_match('(^refs/tags/)', $ref_raw)) {
453 $ref_type = PhabricatorRepositoryPushLog::REFTYPE_TAG;
454 $ref_raw = substr($ref_raw, strlen('refs/tags/'));
455 } else {
456 $ref_type = PhabricatorRepositoryPushLog::REFTYPE_REF;
459 $ref_update = $this->newPushLog()
460 ->setRefType($ref_type)
461 ->setRefName($ref_raw)
462 ->setRefOld($ref_old)
463 ->setRefNew($ref_new);
465 $ref_updates[] = $ref_update;
468 $this->findGitMergeBases($ref_updates);
469 $this->findGitChangeFlags($ref_updates);
471 return $ref_updates;
475 private function findGitMergeBases(array $ref_updates) {
476 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
478 $futures = array();
479 foreach ($ref_updates as $key => $ref_update) {
480 // If the old hash is "00000...", the ref is being created (either a new
481 // branch, or a new tag). If the new hash is "00000...", the ref is being
482 // deleted. If both are nonempty, the ref is being updated. For updates,
483 // we'll figure out the `merge-base` of the old and new objects here. This
484 // lets us reject non-FF changes cheaply; later, we'll figure out exactly
485 // which commits are new.
486 $ref_old = $ref_update->getRefOld();
487 $ref_new = $ref_update->getRefNew();
489 if (($ref_old === self::EMPTY_HASH) ||
490 ($ref_new === self::EMPTY_HASH)) {
491 continue;
494 $futures[$key] = $this->getRepository()->getLocalCommandFuture(
495 'merge-base %s %s',
496 $ref_old,
497 $ref_new);
500 $futures = id(new FutureIterator($futures))
501 ->limit(8);
502 foreach ($futures as $key => $future) {
504 // If 'old' and 'new' have no common ancestors (for example, a force push
505 // which completely rewrites a ref), `git merge-base` will exit with
506 // an error and no output. It would be nice to find a positive test
507 // for this instead, but I couldn't immediately come up with one. See
508 // T4224. Assume this means there are no ancestors.
510 list($err, $stdout) = $future->resolve();
512 if ($err) {
513 $merge_base = null;
514 } else {
515 $merge_base = rtrim($stdout, "\n");
518 $ref_update = $ref_updates[$key];
519 $ref_update->setMergeBase($merge_base);
522 return $ref_updates;
526 private function findGitChangeFlags(array $ref_updates) {
527 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
529 foreach ($ref_updates as $key => $ref_update) {
530 $ref_old = $ref_update->getRefOld();
531 $ref_new = $ref_update->getRefNew();
532 $ref_type = $ref_update->getRefType();
534 $ref_flags = 0;
535 $dangerous = null;
537 if (($ref_old === self::EMPTY_HASH) && ($ref_new === self::EMPTY_HASH)) {
538 // This happens if you try to delete a tag or branch which does not
539 // exist by pushing directly to the ref. Git will warn about it but
540 // allow it. Just call it a delete, without flagging it as dangerous.
541 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
542 } else if ($ref_old === self::EMPTY_HASH) {
543 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;
544 } else if ($ref_new === self::EMPTY_HASH) {
545 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
546 if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) {
547 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;
548 $dangerous = pht(
549 "The change you're attempting to push deletes the branch '%s'.",
550 $ref_update->getRefName());
552 } else {
553 $merge_base = $ref_update->getMergeBase();
554 if ($merge_base == $ref_old) {
555 // This is a fast-forward update to an existing branch.
556 // These are safe.
557 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;
558 } else {
559 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE;
561 // For now, we don't consider deleting or moving tags to be a
562 // "dangerous" update. It's way harder to get wrong and should be easy
563 // to recover from once we have better logging. Only add the dangerous
564 // flag if this ref is a branch.
566 if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) {
567 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;
569 $dangerous = pht(
570 "The change you're attempting to push updates the branch '%s' ".
571 "from '%s' to '%s', but this is not a fast-forward. Pushes ".
572 "which rewrite published branch history are dangerous.",
573 $ref_update->getRefName(),
574 $ref_update->getRefOldShort(),
575 $ref_update->getRefNewShort());
580 $ref_update->setChangeFlags($ref_flags);
581 if ($dangerous !== null) {
582 $ref_update->attachDangerousChangeDescription($dangerous);
586 return $ref_updates;
590 private function findGitContentUpdates(array $ref_updates) {
591 $flag_delete = PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
593 $futures = array();
594 foreach ($ref_updates as $key => $ref_update) {
595 if ($ref_update->hasChangeFlags($flag_delete)) {
596 // Deleting a branch or tag can never create any new commits.
597 continue;
600 // NOTE: This piece of magic finds all new commits, by walking backward
601 // from the new value to the value of *any* existing ref in the
602 // repository. Particularly, this will cover the cases of a new branch, a
603 // completely moved tag, etc.
604 $futures[$key] = $this->getRepository()->getLocalCommandFuture(
605 'log %s %s --not --all --',
606 '--format=%H',
607 gitsprintf('%s', $ref_update->getRefNew()));
610 $content_updates = array();
611 $futures = id(new FutureIterator($futures))
612 ->limit(8);
613 foreach ($futures as $key => $future) {
614 list($stdout) = $future->resolvex();
616 if (!strlen(trim($stdout))) {
617 // This change doesn't have any new commits. One common case of this
618 // is creating a new tag which points at an existing commit.
619 continue;
622 $commits = phutil_split_lines($stdout, $retain_newlines = false);
624 // If we're looking at a branch, mark all of the new commits as on that
625 // branch. It's only possible for these commits to be on updated branches,
626 // since any other branch heads are necessarily behind them.
627 $branch_name = null;
628 $ref_update = $ref_updates[$key];
629 $type_branch = PhabricatorRepositoryPushLog::REFTYPE_BRANCH;
630 if ($ref_update->getRefType() == $type_branch) {
631 $branch_name = $ref_update->getRefName();
634 foreach ($commits as $commit) {
635 if ($branch_name) {
636 $this->gitCommits[$commit][] = $branch_name;
638 $content_updates[$commit] = $this->newPushLog()
639 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)
640 ->setRefNew($commit)
641 ->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD);
645 return $content_updates;
648 /* -( Custom )------------------------------------------------------------- */
650 private function applyCustomHooks(array $updates) {
651 $args = $this->getOriginalArgv();
652 $stdin = $this->getStdin();
653 $console = PhutilConsole::getConsole();
655 $env = array(
656 self::ENV_REPOSITORY => $this->getRepository()->getPHID(),
657 self::ENV_USER => $this->getViewer()->getUsername(),
658 self::ENV_REQUEST => $this->getRequestIdentifier(),
659 self::ENV_REMOTE_PROTOCOL => $this->getRemoteProtocol(),
660 self::ENV_REMOTE_ADDRESS => $this->getRemoteAddress(),
663 $repository = $this->getRepository();
665 $env += $repository->getPassthroughEnvironmentalVariables();
667 $directories = $repository->getHookDirectories();
668 foreach ($directories as $directory) {
669 $hooks = $this->getExecutablesInDirectory($directory);
670 sort($hooks);
671 foreach ($hooks as $hook) {
672 // NOTE: We're explicitly running the hooks in sequential order to
673 // make this more predictable.
674 $future = id(new ExecFuture('%s %Ls', $hook, $args))
675 ->setEnv($env, $wipe_process_env = false)
676 ->write($stdin);
678 list($err, $stdout, $stderr) = $future->resolve();
679 if (!$err) {
680 // This hook ran OK, but echo its output in case there was something
681 // informative.
682 $console->writeOut('%s', $stdout);
683 $console->writeErr('%s', $stderr);
684 continue;
687 $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_EXTERNAL;
688 $this->rejectDetails = basename($hook);
690 throw new DiffusionCommitHookRejectException(
691 pht(
692 "This push was rejected by custom hook script '%s':\n\n%s%s",
693 basename($hook),
694 $stdout,
695 $stderr));
700 private function getExecutablesInDirectory($directory) {
701 $executables = array();
703 if (!Filesystem::pathExists($directory)) {
704 return $executables;
707 foreach (Filesystem::listDirectory($directory) as $path) {
708 $full_path = $directory.DIRECTORY_SEPARATOR.$path;
709 if (!is_executable($full_path)) {
710 // Don't include non-executable files.
711 continue;
714 if (basename($full_path) == 'README') {
715 // Don't include README, even if it is marked as executable. It almost
716 // certainly got caught in the crossfire of a sweeping `chmod`, since
717 // users do this with some frequency.
718 continue;
721 $executables[] = $full_path;
724 return $executables;
728 /* -( Mercurial )---------------------------------------------------------- */
731 private function findMercurialRefUpdates() {
732 $hook = $this->getMercurialHook();
733 switch ($hook) {
734 case 'pretxnchangegroup':
735 return $this->findMercurialChangegroupRefUpdates();
736 case 'prepushkey':
737 return $this->findMercurialPushKeyRefUpdates();
738 default:
739 throw new Exception(pht('Unrecognized hook "%s"!', $hook));
743 private function findMercurialChangegroupRefUpdates() {
744 $hg_node = getenv('HG_NODE');
745 if (!$hg_node) {
746 throw new Exception(
747 pht(
748 'Expected %s in environment!',
749 'HG_NODE'));
752 // NOTE: We need to make sure this is passed to subprocesses, or they won't
753 // be able to see new commits. Mercurial uses this as a marker to determine
754 // whether the pending changes are visible or not.
755 $_ENV['HG_PENDING'] = getenv('HG_PENDING');
756 $repository = $this->getRepository();
758 $futures = array();
760 foreach (array('old', 'new') as $key) {
761 $futures[$key] = $repository->getLocalCommandFuture(
762 'heads --template %s',
763 '{node}\1{branch}\2');
765 // Wipe HG_PENDING out of the old environment so we see the pre-commit
766 // state of the repository.
767 $futures['old']->updateEnv('HG_PENDING', null);
769 $futures['commits'] = $repository->getLocalCommandFuture(
770 'log --rev %s --template %s',
771 hgsprintf('%s:%s', $hg_node, 'tip'),
772 '{node}\1{branch}\2');
774 // Resolve all of the futures now. We don't need the 'commits' future yet,
775 // but it simplifies the logic to just get it out of the way.
776 foreach (new FutureIterator($futures) as $future) {
777 $future->resolve();
780 list($commit_raw) = $futures['commits']->resolvex();
781 $commit_map = $this->parseMercurialCommits($commit_raw);
782 $this->mercurialCommits = $commit_map;
784 // NOTE: `hg heads` exits with an error code and no output if the repository
785 // has no heads. Most commonly this happens on a new repository. We know
786 // we can run `hg` successfully since the `hg log` above didn't error, so
787 // just ignore the error code.
789 list($err, $old_raw) = $futures['old']->resolve();
790 $old_refs = $this->parseMercurialHeads($old_raw);
792 list($err, $new_raw) = $futures['new']->resolve();
793 $new_refs = $this->parseMercurialHeads($new_raw);
795 $all_refs = array_keys($old_refs + $new_refs);
797 $ref_updates = array();
798 foreach ($all_refs as $ref) {
799 $old_heads = idx($old_refs, $ref, array());
800 $new_heads = idx($new_refs, $ref, array());
802 sort($old_heads);
803 sort($new_heads);
805 if (!$old_heads && !$new_heads) {
806 // This should never be possible, as it makes no sense. Explode.
807 throw new Exception(
808 pht(
809 'Mercurial repository has no new or old heads for branch "%s" '.
810 'after push. This makes no sense; rejecting change.',
811 $ref));
814 if ($old_heads === $new_heads) {
815 // No changes to this branch, so skip it.
816 continue;
819 $stray_heads = array();
820 $head_map = array();
822 if ($old_heads && !$new_heads) {
823 // This is a branch deletion with "--close-branch".
824 foreach ($old_heads as $old_head) {
825 $head_map[$old_head] = array(self::EMPTY_HASH);
827 } else if (count($old_heads) > 1) {
828 // HORRIBLE: In Mercurial, branches can have multiple heads. If the
829 // old branch had multiple heads, we need to figure out which new
830 // heads descend from which old heads, so we can tell whether you're
831 // actively creating new heads (dangerous) or just working in a
832 // repository that's already full of garbage (strongly discouraged but
833 // not as inherently dangerous). These cases should be very uncommon.
835 // NOTE: We're only looking for heads on the same branch. The old
836 // tip of the branch may be the branchpoint for other branches, but that
837 // is OK.
839 $dfutures = array();
840 foreach ($old_heads as $old_head) {
841 $dfutures[$old_head] = $repository->getLocalCommandFuture(
842 'log --branch %s --rev %s --template %s',
843 $ref,
844 hgsprintf('(descendants(%s) and head())', $old_head),
845 '{node}\1');
848 foreach (new FutureIterator($dfutures) as $future_head => $dfuture) {
849 list($stdout) = $dfuture->resolvex();
850 $descendant_heads = array_filter(explode("\1", $stdout));
851 if ($descendant_heads) {
852 // This old head has at least one descendant in the push.
853 $head_map[$future_head] = $descendant_heads;
854 } else {
855 // This old head has no descendants, so it is being deleted.
856 $head_map[$future_head] = array(self::EMPTY_HASH);
860 // Now, find all the new stray heads this push creates, if any. These
861 // are new heads which do not descend from the old heads.
862 $seen = array_fuse(array_mergev($head_map));
863 foreach ($new_heads as $new_head) {
864 if ($new_head === self::EMPTY_HASH) {
865 // If a branch head is being deleted, don't insert it as an add.
866 continue;
868 if (empty($seen[$new_head])) {
869 $head_map[self::EMPTY_HASH][] = $new_head;
872 } else if ($old_heads) {
873 $head_map[head($old_heads)] = $new_heads;
874 } else {
875 $head_map[self::EMPTY_HASH] = $new_heads;
878 foreach ($head_map as $old_head => $child_heads) {
879 foreach ($child_heads as $new_head) {
880 if ($new_head === $old_head) {
881 continue;
884 $ref_flags = 0;
885 $dangerous = null;
886 if ($old_head == self::EMPTY_HASH) {
887 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;
888 } else {
889 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;
893 $deletes_existing_head = ($new_head == self::EMPTY_HASH);
894 $splits_existing_head = (count($child_heads) > 1);
895 $creates_duplicate_head = ($old_head == self::EMPTY_HASH) &&
896 (count($head_map) > 1);
898 if ($splits_existing_head || $creates_duplicate_head) {
899 $readable_child_heads = array();
900 foreach ($child_heads as $child_head) {
901 $readable_child_heads[] = substr($child_head, 0, 12);
904 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;
906 if ($splits_existing_head) {
907 // We're splitting an existing head into two or more heads.
908 // This is dangerous, and a super bad idea. Note that we're only
909 // raising this if you're actively splitting a branch head. If a
910 // head split in the past, we don't consider appends to it
911 // to be dangerous.
912 $dangerous = pht(
913 "The change you're attempting to push splits the head of ".
914 "branch '%s' into multiple heads: %s. This is inadvisable ".
915 "and dangerous.",
916 $ref,
917 implode(', ', $readable_child_heads));
918 } else {
919 // We're adding a second (or more) head to a branch. The new
920 // head is not a descendant of any old head.
921 $dangerous = pht(
922 "The change you're attempting to push creates new, divergent ".
923 "heads for the branch '%s': %s. This is inadvisable and ".
924 "dangerous.",
925 $ref,
926 implode(', ', $readable_child_heads));
930 if ($deletes_existing_head) {
931 // TODO: Somewhere in here we should be setting CHANGEFLAG_REWRITE
932 // if we are also creating at least one other head to replace
933 // this one.
935 // NOTE: In Git, this is a dangerous change, but it is not dangerous
936 // in Mercurial. Mercurial branches are version controlled, and
937 // Mercurial does not prompt you for any special flags when pushing
938 // a `--close-branch` commit by default.
940 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
943 $ref_update = $this->newPushLog()
944 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BRANCH)
945 ->setRefName($ref)
946 ->setRefOld($old_head)
947 ->setRefNew($new_head)
948 ->setChangeFlags($ref_flags);
950 if ($dangerous !== null) {
951 $ref_update->attachDangerousChangeDescription($dangerous);
954 $ref_updates[] = $ref_update;
959 return $ref_updates;
962 private function findMercurialPushKeyRefUpdates() {
963 $key_namespace = getenv('HG_NAMESPACE');
965 if ($key_namespace === 'phases') {
966 // Mercurial changes commit phases as part of normal push operations. We
967 // just ignore these, as they don't seem to represent anything
968 // interesting.
969 return array();
972 $key_name = getenv('HG_KEY');
974 $key_old = getenv('HG_OLD');
975 if (!strlen($key_old)) {
976 $key_old = null;
979 $key_new = getenv('HG_NEW');
980 if (!strlen($key_new)) {
981 $key_new = null;
984 if ($key_namespace !== 'bookmarks') {
985 throw new Exception(
986 pht(
987 "Unknown Mercurial key namespace '%s', with key '%s' (%s -> %s). ".
988 "Rejecting push.",
989 $key_namespace,
990 $key_name,
991 coalesce($key_old, pht('null')),
992 coalesce($key_new, pht('null'))));
995 if ($key_old === $key_new) {
996 // We get a callback when the bookmark doesn't change. Just ignore this,
997 // as it's a no-op.
998 return array();
1001 $ref_flags = 0;
1002 $merge_base = null;
1003 if ($key_old === null) {
1004 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;
1005 } else if ($key_new === null) {
1006 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
1007 } else {
1008 list($merge_base_raw) = $this->getRepository()->execxLocalCommand(
1009 'log --template %s --rev %s',
1010 '{node}',
1011 hgsprintf('ancestor(%s, %s)', $key_old, $key_new));
1013 if (strlen(trim($merge_base_raw))) {
1014 $merge_base = trim($merge_base_raw);
1017 if ($merge_base && ($merge_base === $key_old)) {
1018 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;
1019 } else {
1020 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE;
1024 $ref_update = $this->newPushLog()
1025 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BOOKMARK)
1026 ->setRefName($key_name)
1027 ->setRefOld(coalesce($key_old, self::EMPTY_HASH))
1028 ->setRefNew(coalesce($key_new, self::EMPTY_HASH))
1029 ->setChangeFlags($ref_flags);
1031 return array($ref_update);
1034 private function findMercurialContentUpdates(array $ref_updates) {
1035 $content_updates = array();
1037 foreach ($this->mercurialCommits as $commit => $branches) {
1038 $content_updates[$commit] = $this->newPushLog()
1039 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)
1040 ->setRefNew($commit)
1041 ->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD);
1044 return $content_updates;
1047 private function parseMercurialCommits($raw) {
1048 $commits_lines = explode("\2", $raw);
1049 $commits_lines = array_filter($commits_lines);
1050 $commit_map = array();
1051 foreach ($commits_lines as $commit_line) {
1052 list($node, $branch) = explode("\1", $commit_line);
1053 $commit_map[$node] = array($branch);
1056 return $commit_map;
1059 private function parseMercurialHeads($raw) {
1060 $heads_map = $this->parseMercurialCommits($raw);
1062 $heads = array();
1063 foreach ($heads_map as $commit => $branches) {
1064 foreach ($branches as $branch) {
1065 $heads[$branch][] = $commit;
1069 return $heads;
1073 /* -( Subversion )--------------------------------------------------------- */
1076 private function findSubversionRefUpdates() {
1077 // Subversion doesn't have any kind of mutable ref metadata.
1078 return array();
1081 private function findSubversionContentUpdates(array $ref_updates) {
1082 list($youngest) = execx(
1083 'svnlook youngest %s',
1084 $this->subversionRepository);
1085 $ref_new = (int)$youngest + 1;
1087 $ref_flags = 0;
1088 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;
1089 $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;
1091 $ref_content = $this->newPushLog()
1092 ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)
1093 ->setRefNew($ref_new)
1094 ->setChangeFlags($ref_flags);
1096 return array($ref_content);
1100 /* -( Internals )---------------------------------------------------------- */
1103 private function newPushLog() {
1104 // NOTE: We generate PHIDs up front so the Herald transcripts can pick them
1105 // up.
1106 $phid = id(new PhabricatorRepositoryPushLog())->generatePHID();
1108 $device = AlmanacKeys::getLiveDevice();
1109 if ($device) {
1110 $device_phid = $device->getPHID();
1111 } else {
1112 $device_phid = null;
1115 return PhabricatorRepositoryPushLog::initializeNewLog($this->getViewer())
1116 ->setPHID($phid)
1117 ->setDevicePHID($device_phid)
1118 ->setRepositoryPHID($this->getRepository()->getPHID())
1119 ->attachRepository($this->getRepository())
1120 ->setEpoch(PhabricatorTime::getNow());
1123 private function newPushEvent() {
1124 $viewer = $this->getViewer();
1126 $hook_start = $this->getStartTime();
1128 $event = PhabricatorRepositoryPushEvent::initializeNewEvent($viewer)
1129 ->setRepositoryPHID($this->getRepository()->getPHID())
1130 ->setRemoteAddress($this->getRemoteAddress())
1131 ->setRemoteProtocol($this->getRemoteProtocol())
1132 ->setEpoch(PhabricatorTime::getNow())
1133 ->setHookWait(phutil_microseconds_since($hook_start));
1135 $identifier = $this->getRequestIdentifier();
1136 if (strlen($identifier)) {
1137 $event->setRequestIdentifier($identifier);
1140 return $event;
1143 private function rejectEnormousChanges(array $content_updates) {
1144 $repository = $this->getRepository();
1145 if ($repository->shouldAllowEnormousChanges()) {
1146 return;
1149 // See T13142. Don't cache more than 64MB of changesets. For normal small
1150 // pushes, caching everything here can let us hit the cache from Herald if
1151 // we need to run content rules, which speeds things up a bit. For large
1152 // pushes, we may not be able to hold everything in memory.
1153 $cache_limit = 1024 * 1024 * 64;
1155 foreach ($content_updates as $update) {
1156 $identifier = $update->getRefNew();
1157 try {
1158 $info = $this->loadChangesetsForCommit($identifier);
1159 list($changesets, $size) = $info;
1161 if ($this->changesetsSize + $size <= $cache_limit) {
1162 $this->changesets[$identifier] = $changesets;
1163 $this->changesetsSize += $size;
1165 } catch (Exception $ex) {
1166 $this->changesets[$identifier] = $ex;
1168 $message = pht(
1169 'ENORMOUS CHANGE'.
1170 "\n".
1171 'Enormous change protection is enabled for this repository, but '.
1172 'you are pushing an enormous change ("%s"). Edit the repository '.
1173 'configuration before making enormous changes.'.
1174 "\n\n".
1175 "Content Exception: %s",
1176 $identifier,
1177 $ex->getMessage());
1179 throw new DiffusionCommitHookRejectException($message);
1184 private function loadChangesetsForCommit($identifier) {
1185 $byte_limit = HeraldCommitAdapter::getEnormousByteLimit();
1186 $time_limit = HeraldCommitAdapter::getEnormousTimeLimit();
1188 $vcs = $this->getRepository()->getVersionControlSystem();
1189 switch ($vcs) {
1190 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
1191 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
1192 // For git and hg, we can use normal commands.
1193 $drequest = DiffusionRequest::newFromDictionary(
1194 array(
1195 'repository' => $this->getRepository(),
1196 'user' => $this->getViewer(),
1197 'commit' => $identifier,
1200 $raw_diff = DiffusionRawDiffQuery::newFromDiffusionRequest($drequest)
1201 ->setTimeout($time_limit)
1202 ->setByteLimit($byte_limit)
1203 ->setLinesOfContext(0)
1204 ->executeInline();
1205 break;
1206 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
1207 // TODO: This diff has 3 lines of context, which produces slightly
1208 // incorrect "added file content" and "removed file content" results.
1209 // This may also choke on binaries, but "svnlook diff" does not support
1210 // the "--diff-cmd" flag.
1212 // For subversion, we need to use `svnlook`.
1213 $future = new ExecFuture(
1214 'svnlook diff -t %s %s',
1215 $this->subversionTransaction,
1216 $this->subversionRepository);
1218 $future->setTimeout($time_limit);
1219 $future->setStdoutSizeLimit($byte_limit);
1220 $future->setStderrSizeLimit($byte_limit);
1222 list($raw_diff) = $future->resolvex();
1223 break;
1224 default:
1225 throw new Exception(pht("Unknown VCS '%s!'", $vcs));
1228 if (strlen($raw_diff) >= $byte_limit) {
1229 throw new Exception(
1230 pht(
1231 'The raw text of this change ("%s") is enormous (larger than %s '.
1232 'bytes).',
1233 $identifier,
1234 new PhutilNumber($byte_limit)));
1237 if (!strlen($raw_diff)) {
1238 // If the commit is actually empty, just return no changesets.
1239 return array(array(), 0);
1242 $parser = new ArcanistDiffParser();
1243 $changes = $parser->parseDiff($raw_diff);
1244 $diff = DifferentialDiff::newEphemeralFromRawChanges(
1245 $changes);
1247 $changesets = $diff->getChangesets();
1248 $size = strlen($raw_diff);
1250 return array($changesets, $size);
1253 public function getChangesetsForCommit($identifier) {
1254 if (isset($this->changesets[$identifier])) {
1255 $cached = $this->changesets[$identifier];
1257 if ($cached instanceof Exception) {
1258 throw $cached;
1261 return $cached;
1264 $info = $this->loadChangesetsForCommit($identifier);
1265 list($changesets, $size) = $info;
1266 return $changesets;
1269 private function rejectOversizedFiles(array $content_updates) {
1270 $repository = $this->getRepository();
1272 $limit = $repository->getFilesizeLimit();
1273 if (!$limit) {
1274 return;
1277 foreach ($content_updates as $update) {
1278 $identifier = $update->getRefNew();
1280 $sizes = $this->getFileSizesForCommit($identifier);
1282 foreach ($sizes as $path => $size) {
1283 if ($size <= $limit) {
1284 continue;
1287 $message = pht(
1288 'OVERSIZED FILE'.
1289 "\n".
1290 'This repository ("%s") is configured with a maximum individual '.
1291 'file size limit, but you are pushing a change ("%s") which causes '.
1292 'the size of a file ("%s") to exceed the limit. The commit makes '.
1293 'the file %s bytes long, but the limit for this repository is '.
1294 '%s bytes.',
1295 $repository->getDisplayName(),
1296 $identifier,
1297 $path,
1298 new PhutilNumber($size),
1299 new PhutilNumber($limit));
1301 throw new DiffusionCommitHookRejectException($message);
1306 private function rejectCommitsAffectingTooManyPaths(array $content_updates) {
1307 $repository = $this->getRepository();
1309 $limit = $repository->getTouchLimit();
1310 if (!$limit) {
1311 return;
1314 foreach ($content_updates as $update) {
1315 $identifier = $update->getRefNew();
1317 $sizes = $this->getFileSizesForCommit($identifier);
1318 if (count($sizes) > $limit) {
1319 $message = pht(
1320 'COMMIT AFFECTS TOO MANY PATHS'.
1321 "\n".
1322 'This repository ("%s") is configured with a touched files limit '.
1323 'that caps the maximum number of paths any single commit may '.
1324 'affect. You are pushing a change ("%s") which exceeds this '.
1325 'limit: it affects %s paths, but the largest number of paths any '.
1326 'commit may affect is %s paths.',
1327 $repository->getDisplayName(),
1328 $identifier,
1329 phutil_count($sizes),
1330 new PhutilNumber($limit));
1332 throw new DiffusionCommitHookRejectException($message);
1337 public function getFileSizesForCommit($identifier) {
1338 if (!isset($this->filesizeCache[$identifier])) {
1339 $file_sizes = $this->loadFileSizesForCommit($identifier);
1340 $this->filesizeCache[$identifier] = $file_sizes;
1343 return $this->filesizeCache[$identifier];
1346 private function loadFileSizesForCommit($identifier) {
1347 $repository = $this->getRepository();
1349 return id(new DiffusionLowLevelFilesizeQuery())
1350 ->setRepository($repository)
1351 ->withIdentifier($identifier)
1352 ->execute();
1355 public function loadCommitRefForCommit($identifier) {
1356 $repository = $this->getRepository();
1357 $vcs = $repository->getVersionControlSystem();
1358 switch ($vcs) {
1359 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
1360 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
1361 return id(new DiffusionLowLevelCommitQuery())
1362 ->setRepository($repository)
1363 ->withIdentifier($identifier)
1364 ->execute();
1365 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
1366 // For subversion, we need to use `svnlook`.
1367 list($message) = execx(
1368 'svnlook log -t %s %s',
1369 $this->subversionTransaction,
1370 $this->subversionRepository);
1372 return id(new DiffusionCommitRef())
1373 ->setMessage($message);
1374 break;
1375 default:
1376 throw new Exception(pht("Unknown VCS '%s!'", $vcs));
1380 public function loadBranches($identifier) {
1381 $repository = $this->getRepository();
1382 $vcs = $repository->getVersionControlSystem();
1383 switch ($vcs) {
1384 case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
1385 return idx($this->gitCommits, $identifier, array());
1386 case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
1387 // NOTE: This will be "the branch the commit was made to", not
1388 // "a list of all branch heads which descend from the commit".
1389 // This is consistent with Mercurial, but possibly confusing.
1390 return idx($this->mercurialCommits, $identifier, array());
1391 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
1392 // Subversion doesn't have branches.
1393 return array();
1397 private function loadCommitInfoForWorker(array $all_updates) {
1398 $type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT;
1400 $map = array();
1401 foreach ($all_updates as $update) {
1402 if ($update->getRefType() != $type_commit) {
1403 continue;
1405 $map[$update->getRefNew()] = array();
1408 foreach ($map as $identifier => $info) {
1409 $ref = $this->loadCommitRefForCommit($identifier);
1410 $map[$identifier] += array(
1411 'summary' => $ref->getSummary(),
1412 'branches' => $this->loadBranches($identifier),
1416 return $map;
1419 private function isInitialImport(array $all_updates) {
1420 $repository = $this->getRepository();
1422 $vcs = $repository->getVersionControlSystem();
1423 switch ($vcs) {
1424 case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
1425 // There is no meaningful way to import history into Subversion by
1426 // pushing.
1427 return false;
1428 default:
1429 break;
1432 // Now, apply a heuristic to guess whether this is a normal commit or
1433 // an initial import. We guess something is an initial import if:
1435 // - the repository is currently empty; and
1436 // - it pushes more than 7 commits at once.
1438 // The number "7" is chosen arbitrarily as seeming reasonable. We could
1439 // also look at author data (do the commits come from multiple different
1440 // authors?) and commit date data (is the oldest commit more than 48 hours
1441 // old), but we don't have immediate access to those and this simple
1442 // heuristic might be good enough.
1444 $commit_count = 0;
1445 $type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT;
1446 foreach ($all_updates as $update) {
1447 if ($update->getRefType() != $type_commit) {
1448 continue;
1450 $commit_count++;
1453 if ($commit_count <= PhabricatorRepository::IMPORT_THRESHOLD) {
1454 // If this pushes a very small number of commits, assume it's an
1455 // initial commit or stack of a few initial commits.
1456 return false;
1459 $any_commits = id(new DiffusionCommitQuery())
1460 ->setViewer($this->getViewer())
1461 ->withRepository($repository)
1462 ->setLimit(1)
1463 ->execute();
1465 if ($any_commits) {
1466 // If the repository already has commits, this isn't an import.
1467 return false;
1470 return true;