4 * @task config Configuring the Hook Engine
5 * @task hook Hook Execution
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';
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();
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;
53 public function getRemoteProtocol() {
54 return $this->remoteProtocol
;
57 public function setRemoteAddress($remote_address) {
58 $this->remoteAddress
= $remote_address;
62 public function getRemoteAddress() {
63 return $this->remoteAddress
;
66 public function setRequestIdentifier($request_identifier) {
67 $this->requestIdentifier
= $request_identifier;
71 public function getRequestIdentifier() {
72 return $this->requestIdentifier
;
75 public function setStartTime($start_time) {
76 $this->startTime
= $start_time;
80 public function getStartTime() {
81 return $this->startTime
;
84 public function setSubversionTransactionInfo($transaction, $repository) {
85 $this->subversionTransaction
= $transaction;
86 $this->subversionRepository
= $repository;
90 public function setStdin($stdin) {
91 $this->stdin
= $stdin;
95 public function getStdin() {
99 public function setOriginalArgv(array $original_argv) {
100 $this->originalArgv
= $original_argv;
104 public function getOriginalArgv() {
105 return $this->originalArgv
;
108 public function setRepository(PhabricatorRepository
$repository) {
109 $this->repository
= $repository;
113 public function getRepository() {
114 return $this->repository
;
117 public function setViewer(PhabricatorUser
$viewer) {
118 $this->viewer
= $viewer;
122 public function getViewer() {
123 return $this->viewer
;
126 public function setMercurialHook($mercurial_hook) {
127 $this->mercurialHook
= $mercurial_hook;
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;
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
;
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);
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
;
179 if (!$is_initial_import) {
180 $this->rejectCommitsAffectingTooManyPaths($content_updates);
182 } catch (DiffusionCommitHookRejectException
$ex) {
183 $this->rejectCode
= PhabricatorRepositoryPushLog
::REJECT_TOUCHES
;
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
;
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
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
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);
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
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',
244 'eventPHID' => $event->getPHID(),
245 'emailPHIDs' => array_values($this->emailPHIDs
),
246 'info' => $this->loadCommitInfoForWorker($all_updates),
249 'priority' => PhabricatorWorker
::PRIORITY_ALERTS
,
256 private function findRefUpdates() {
257 $type = $this->getRepository()->getVersionControlSystem();
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();
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()) {
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.
286 // We either have a branch deletion or a non fast-forward branch update.
287 // Format a message and reject the push.
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();
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);
311 throw new Exception(pht('Unsupported repository type "%s"!', $type));
316 /* -( Herald )------------------------------------------------------------- */
318 private function applyHeraldRefRules(array $ref_updates) {
319 $this->applyHeraldRules(
321 new HeraldPreCommitRefAdapter());
324 private function applyHeraldContentRules(array $content_updates) {
325 $this->applyHeraldRules(
327 new HeraldPreCommitContentAdapter());
330 private function applyHeraldRules(
332 HeraldAdapter
$adapter_template) {
338 $viewer = $this->getViewer();
341 ->setHookEngine($this)
342 ->setActingAsPHID($viewer->getPHID());
344 $engine = new HeraldEngine();
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;
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(
398 "This push was rejected by Herald push rule %s.\n".
403 $rule->getMonogram(),
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()))
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
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/'));
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);
475 private function findGitMergeBases(array $ref_updates) {
476 assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
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
)) {
494 $futures[$key] = $this->getRepository()->getLocalCommandFuture(
500 $futures = id(new FutureIterator($futures))
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();
515 $merge_base = rtrim($stdout, "\n");
518 $ref_update = $ref_updates[$key];
519 $ref_update->setMergeBase($merge_base);
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();
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
;
549 "The change you're attempting to push deletes the branch '%s'.",
550 $ref_update->getRefName());
553 $merge_base = $ref_update->getMergeBase();
554 if ($merge_base == $ref_old) {
555 // This is a fast-forward update to an existing branch.
557 $ref_flags |
= PhabricatorRepositoryPushLog
::CHANGEFLAG_APPEND
;
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
;
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);
590 private function findGitContentUpdates(array $ref_updates) {
591 $flag_delete = PhabricatorRepositoryPushLog
::CHANGEFLAG_DELETE
;
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.
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 --',
607 gitsprintf('%s', $ref_update->getRefNew()));
610 $content_updates = array();
611 $futures = id(new FutureIterator($futures))
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.
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.
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) {
636 $this->gitCommits
[$commit][] = $branch_name;
638 $content_updates[$commit] = $this->newPushLog()
639 ->setRefType(PhabricatorRepositoryPushLog
::REFTYPE_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();
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);
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)
678 list($err, $stdout, $stderr) = $future->resolve();
680 // This hook ran OK, but echo its output in case there was something
682 $console->writeOut('%s', $stdout);
683 $console->writeErr('%s', $stderr);
687 $this->rejectCode
= PhabricatorRepositoryPushLog
::REJECT_EXTERNAL
;
688 $this->rejectDetails
= basename($hook);
690 throw new DiffusionCommitHookRejectException(
692 "This push was rejected by custom hook script '%s':\n\n%s%s",
700 private function getExecutablesInDirectory($directory) {
701 $executables = array();
703 if (!Filesystem
::pathExists($directory)) {
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.
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.
721 $executables[] = $full_path;
728 /* -( Mercurial )---------------------------------------------------------- */
731 private function findMercurialRefUpdates() {
732 $hook = $this->getMercurialHook();
734 case 'pretxnchangegroup':
735 return $this->findMercurialChangegroupRefUpdates();
737 return $this->findMercurialPushKeyRefUpdates();
739 throw new Exception(pht('Unrecognized hook "%s"!', $hook));
743 private function findMercurialChangegroupRefUpdates() {
744 $hg_node = getenv('HG_NODE');
748 'Expected %s in environment!',
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();
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) {
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());
805 if (!$old_heads && !$new_heads) {
806 // This should never be possible, as it makes no sense. Explode.
809 'Mercurial repository has no new or old heads for branch "%s" '.
810 'after push. This makes no sense; rejecting change.',
814 if ($old_heads === $new_heads) {
815 // No changes to this branch, so skip it.
819 $stray_heads = 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
840 foreach ($old_heads as $old_head) {
841 $dfutures[$old_head] = $repository->getLocalCommandFuture(
842 'log --branch %s --rev %s --template %s',
844 hgsprintf('(descendants(%s) and head())', $old_head),
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;
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.
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;
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) {
886 if ($old_head == self
::EMPTY_HASH
) {
887 $ref_flags |
= PhabricatorRepositoryPushLog
::CHANGEFLAG_ADD
;
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
913 "The change you're attempting to push splits the head of ".
914 "branch '%s' into multiple heads: %s. This is inadvisable ".
917 implode(', ', $readable_child_heads));
919 // We're adding a second (or more) head to a branch. The new
920 // head is not a descendant of any old head.
922 "The change you're attempting to push creates new, divergent ".
923 "heads for the branch '%s': %s. This is inadvisable and ".
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
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
)
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;
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
972 $key_name = getenv('HG_KEY');
974 $key_old = getenv('HG_OLD');
975 if (!strlen($key_old)) {
979 $key_new = getenv('HG_NEW');
980 if (!strlen($key_new)) {
984 if ($key_namespace !== 'bookmarks') {
987 "Unknown Mercurial key namespace '%s', with key '%s' (%s -> %s). ".
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,
1003 if ($key_old === null) {
1004 $ref_flags |
= PhabricatorRepositoryPushLog
::CHANGEFLAG_ADD
;
1005 } else if ($key_new === null) {
1006 $ref_flags |
= PhabricatorRepositoryPushLog
::CHANGEFLAG_DELETE
;
1008 list($merge_base_raw) = $this->getRepository()->execxLocalCommand(
1009 'log --template %s --rev %s',
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
;
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);
1059 private function parseMercurialHeads($raw) {
1060 $heads_map = $this->parseMercurialCommits($raw);
1063 foreach ($heads_map as $commit => $branches) {
1064 foreach ($branches as $branch) {
1065 $heads[$branch][] = $commit;
1073 /* -( Subversion )--------------------------------------------------------- */
1076 private function findSubversionRefUpdates() {
1077 // Subversion doesn't have any kind of mutable ref metadata.
1081 private function findSubversionContentUpdates(array $ref_updates) {
1082 list($youngest) = execx(
1083 'svnlook youngest %s',
1084 $this->subversionRepository
);
1085 $ref_new = (int)$youngest +
1;
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
1106 $phid = id(new PhabricatorRepositoryPushLog())->generatePHID();
1108 $device = AlmanacKeys
::getLiveDevice();
1110 $device_phid = $device->getPHID();
1112 $device_phid = null;
1115 return PhabricatorRepositoryPushLog
::initializeNewLog($this->getViewer())
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);
1143 private function rejectEnormousChanges(array $content_updates) {
1144 $repository = $this->getRepository();
1145 if ($repository->shouldAllowEnormousChanges()) {
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();
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;
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.'.
1175 "Content Exception: %s",
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();
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(
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)
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();
1225 throw new Exception(pht("Unknown VCS '%s!'", $vcs));
1228 if (strlen($raw_diff) >= $byte_limit) {
1229 throw new Exception(
1231 'The raw text of this change ("%s") is enormous (larger than %s '.
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(
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
) {
1264 $info = $this->loadChangesetsForCommit($identifier);
1265 list($changesets, $size) = $info;
1269 private function rejectOversizedFiles(array $content_updates) {
1270 $repository = $this->getRepository();
1272 $limit = $repository->getFilesizeLimit();
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) {
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 '.
1295 $repository->getDisplayName(),
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();
1314 foreach ($content_updates as $update) {
1315 $identifier = $update->getRefNew();
1317 $sizes = $this->getFileSizesForCommit($identifier);
1318 if (count($sizes) > $limit) {
1320 'COMMIT AFFECTS TOO MANY PATHS'.
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(),
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)
1355 public function loadCommitRefForCommit($identifier) {
1356 $repository = $this->getRepository();
1357 $vcs = $repository->getVersionControlSystem();
1359 case PhabricatorRepositoryType
::REPOSITORY_TYPE_GIT
:
1360 case PhabricatorRepositoryType
::REPOSITORY_TYPE_MERCURIAL
:
1361 return id(new DiffusionLowLevelCommitQuery())
1362 ->setRepository($repository)
1363 ->withIdentifier($identifier)
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);
1376 throw new Exception(pht("Unknown VCS '%s!'", $vcs));
1380 public function loadBranches($identifier) {
1381 $repository = $this->getRepository();
1382 $vcs = $repository->getVersionControlSystem();
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.
1397 private function loadCommitInfoForWorker(array $all_updates) {
1398 $type_commit = PhabricatorRepositoryPushLog
::REFTYPE_COMMIT
;
1401 foreach ($all_updates as $update) {
1402 if ($update->getRefType() != $type_commit) {
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),
1419 private function isInitialImport(array $all_updates) {
1420 $repository = $this->getRepository();
1422 $vcs = $repository->getVersionControlSystem();
1424 case PhabricatorRepositoryType
::REPOSITORY_TYPE_SVN
:
1425 // There is no meaningful way to import history into Subversion by
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.
1445 $type_commit = PhabricatorRepositoryPushLog
::REFTYPE_COMMIT
;
1446 foreach ($all_updates as $update) {
1447 if ($update->getRefType() != $type_commit) {
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.
1459 $any_commits = id(new DiffusionCommitQuery())
1460 ->setViewer($this->getViewer())
1461 ->withRepository($repository)
1466 // If the repository already has commits, this isn't an import.