Path: blob/master/src/applications/diffusion/engine/DiffusionCommitHookEngine.php
12241 views
<?php12/**3* @task config Configuring the Hook Engine4* @task hook Hook Execution5* @task git Git Hooks6* @task hg Mercurial Hooks7* @task svn Subversion Hooks8* @task internal Internals9*/10final class DiffusionCommitHookEngine extends Phobject {1112const ENV_REPOSITORY = 'PHABRICATOR_REPOSITORY';13const ENV_USER = 'PHABRICATOR_USER';14const ENV_REQUEST = 'PHABRICATOR_REQUEST';15const ENV_REMOTE_ADDRESS = 'PHABRICATOR_REMOTE_ADDRESS';16const ENV_REMOTE_PROTOCOL = 'PHABRICATOR_REMOTE_PROTOCOL';1718const EMPTY_HASH = '0000000000000000000000000000000000000000';1920private $viewer;21private $repository;22private $stdin;23private $originalArgv;24private $subversionTransaction;25private $subversionRepository;26private $remoteAddress;27private $remoteProtocol;28private $requestIdentifier;29private $transactionKey;30private $mercurialHook;31private $mercurialCommits = array();32private $gitCommits = array();33private $startTime;3435private $heraldViewerProjects;36private $rejectCode = PhabricatorRepositoryPushLog::REJECT_BROKEN;37private $rejectDetails;38private $emailPHIDs = array();39private $changesets = array();40private $changesetsSize = 0;41private $filesizeCache = array();424344/* -( Config )------------------------------------------------------------- */454647public function setRemoteProtocol($remote_protocol) {48$this->remoteProtocol = $remote_protocol;49return $this;50}5152public function getRemoteProtocol() {53return $this->remoteProtocol;54}5556public function setRemoteAddress($remote_address) {57$this->remoteAddress = $remote_address;58return $this;59}6061public function getRemoteAddress() {62return $this->remoteAddress;63}6465public function setRequestIdentifier($request_identifier) {66$this->requestIdentifier = $request_identifier;67return $this;68}6970public function getRequestIdentifier() {71return $this->requestIdentifier;72}7374public function setStartTime($start_time) {75$this->startTime = $start_time;76return $this;77}7879public function getStartTime() {80return $this->startTime;81}8283public function setSubversionTransactionInfo($transaction, $repository) {84$this->subversionTransaction = $transaction;85$this->subversionRepository = $repository;86return $this;87}8889public function setStdin($stdin) {90$this->stdin = $stdin;91return $this;92}9394public function getStdin() {95return $this->stdin;96}9798public function setOriginalArgv(array $original_argv) {99$this->originalArgv = $original_argv;100return $this;101}102103public function getOriginalArgv() {104return $this->originalArgv;105}106107public function setRepository(PhabricatorRepository $repository) {108$this->repository = $repository;109return $this;110}111112public function getRepository() {113return $this->repository;114}115116public function setViewer(PhabricatorUser $viewer) {117$this->viewer = $viewer;118return $this;119}120121public function getViewer() {122return $this->viewer;123}124125public function setMercurialHook($mercurial_hook) {126$this->mercurialHook = $mercurial_hook;127return $this;128}129130public function getMercurialHook() {131return $this->mercurialHook;132}133134135/* -( Hook Execution )----------------------------------------------------- */136137138public function execute() {139$ref_updates = $this->findRefUpdates();140$all_updates = $ref_updates;141142$caught = null;143try {144145try {146$this->rejectDangerousChanges($ref_updates);147} catch (DiffusionCommitHookRejectException $ex) {148// If we're rejecting dangerous changes, flag everything that we've149// seen as rejected so it's clear that none of it was accepted.150$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_DANGEROUS;151throw $ex;152}153154$content_updates = $this->findContentUpdates($ref_updates);155$all_updates = array_merge($ref_updates, $content_updates);156157// If this is an "initial import" (a sizable push to a previously empty158// repository) we'll allow enormous changes and disable Herald rules.159// These rulesets can consume a large amount of time and memory and are160// generally not relevant when importing repository history.161$is_initial_import = $this->isInitialImport($all_updates);162163if (!$is_initial_import) {164$this->applyHeraldRefRules($ref_updates);165}166167try {168if (!$is_initial_import) {169$this->rejectOversizedFiles($content_updates);170}171} catch (DiffusionCommitHookRejectException $ex) {172// If we're rejecting oversized files, flag everything.173$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_OVERSIZED;174throw $ex;175}176177try {178if (!$is_initial_import) {179$this->rejectCommitsAffectingTooManyPaths($content_updates);180}181} catch (DiffusionCommitHookRejectException $ex) {182$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_TOUCHES;183throw $ex;184}185186try {187if (!$is_initial_import) {188$this->rejectEnormousChanges($content_updates);189}190} catch (DiffusionCommitHookRejectException $ex) {191// If we're rejecting enormous changes, flag everything.192$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_ENORMOUS;193throw $ex;194}195196if (!$is_initial_import) {197$this->applyHeraldContentRules($content_updates);198}199200// Run custom scripts in `hook.d/` directories.201$this->applyCustomHooks($all_updates);202203// If we make it this far, we're accepting these changes. Mark all the204// logs as accepted.205$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_ACCEPT;206} catch (Exception $ex) {207// We'll throw this again in a minute, but we want to save all the logs208// first.209$caught = $ex;210}211212// Save all the logs no matter what the outcome was.213$event = $this->newPushEvent();214215$event->setRejectCode($this->rejectCode);216$event->setRejectDetails($this->rejectDetails);217218$event->saveWithLogs($all_updates);219220if ($caught) {221throw $caught;222}223224// If this went through cleanly and was an import, set the importing flag225// on the repository. It will be cleared once we fully process everything.226227if ($is_initial_import) {228$repository = $this->getRepository();229$repository->markImporting();230}231232if ($this->emailPHIDs) {233// If Herald rules triggered email to users, queue a worker to send the234// mail. We do this out-of-process so that we block pushes as briefly235// as possible.236237// (We do need to pull some commit info here because the commit objects238// may not exist yet when this worker runs, which could be immediately.)239240PhabricatorWorker::scheduleTask(241'PhabricatorRepositoryPushMailWorker',242array(243'eventPHID' => $event->getPHID(),244'emailPHIDs' => array_values($this->emailPHIDs),245'info' => $this->loadCommitInfoForWorker($all_updates),246),247array(248'priority' => PhabricatorWorker::PRIORITY_ALERTS,249));250}251252return 0;253}254255private function findRefUpdates() {256$type = $this->getRepository()->getVersionControlSystem();257switch ($type) {258case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:259return $this->findGitRefUpdates();260case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:261return $this->findMercurialRefUpdates();262case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:263return $this->findSubversionRefUpdates();264default:265throw new Exception(pht('Unsupported repository type "%s"!', $type));266}267}268269private function rejectDangerousChanges(array $ref_updates) {270assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');271272$repository = $this->getRepository();273if ($repository->shouldAllowDangerousChanges()) {274return;275}276277$flag_dangerous = PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;278279foreach ($ref_updates as $ref_update) {280if (!$ref_update->hasChangeFlags($flag_dangerous)) {281// This is not a dangerous change.282continue;283}284285// We either have a branch deletion or a non fast-forward branch update.286// Format a message and reject the push.287288$message = pht(289"DANGEROUS CHANGE: %s\n".290"Dangerous change protection is enabled for this repository.\n".291"Edit the repository configuration before making dangerous changes.",292$ref_update->getDangerousChangeDescription());293294throw new DiffusionCommitHookRejectException($message);295}296}297298private function findContentUpdates(array $ref_updates) {299assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');300301$type = $this->getRepository()->getVersionControlSystem();302switch ($type) {303case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:304return $this->findGitContentUpdates($ref_updates);305case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:306return $this->findMercurialContentUpdates($ref_updates);307case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:308return $this->findSubversionContentUpdates($ref_updates);309default:310throw new Exception(pht('Unsupported repository type "%s"!', $type));311}312}313314315/* -( Herald )------------------------------------------------------------- */316317private function applyHeraldRefRules(array $ref_updates) {318$this->applyHeraldRules(319$ref_updates,320new HeraldPreCommitRefAdapter());321}322323private function applyHeraldContentRules(array $content_updates) {324$this->applyHeraldRules(325$content_updates,326new HeraldPreCommitContentAdapter());327}328329private function applyHeraldRules(330array $updates,331HeraldAdapter $adapter_template) {332333if (!$updates) {334return;335}336337$viewer = $this->getViewer();338339$adapter_template340->setHookEngine($this)341->setActingAsPHID($viewer->getPHID());342343$engine = new HeraldEngine();344$rules = null;345$blocking_effect = null;346$blocked_update = null;347$blocking_xscript = null;348foreach ($updates as $update) {349$adapter = id(clone $adapter_template)350->setPushLog($update);351352if ($rules === null) {353$rules = $engine->loadRulesForAdapter($adapter);354}355356$effects = $engine->applyRules($rules, $adapter);357$engine->applyEffects($effects, $adapter, $rules);358$xscript = $engine->getTranscript();359360// Store any PHIDs we want to send email to for later.361foreach ($adapter->getEmailPHIDs() as $email_phid) {362$this->emailPHIDs[$email_phid] = $email_phid;363}364365$block_action = DiffusionBlockHeraldAction::ACTIONCONST;366367if ($blocking_effect === null) {368foreach ($effects as $effect) {369if ($effect->getAction() == $block_action) {370$blocking_effect = $effect;371$blocked_update = $update;372$blocking_xscript = $xscript;373break;374}375}376}377}378379if ($blocking_effect) {380$rule = $blocking_effect->getRule();381382$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_HERALD;383$this->rejectDetails = $rule->getPHID();384385$message = $blocking_effect->getTarget();386if (!strlen($message)) {387$message = pht('(None.)');388}389390$blocked_ref_name = coalesce(391$blocked_update->getRefName(),392$blocked_update->getRefNewShort());393$blocked_name = $blocked_update->getRefType().'/'.$blocked_ref_name;394395throw new DiffusionCommitHookRejectException(396pht(397"This push was rejected by Herald push rule %s.\n".398" Change: %s\n".399" Rule: %s\n".400" Reason: %s\n".401"Transcript: %s",402$rule->getMonogram(),403$blocked_name,404$rule->getName(),405$message,406PhabricatorEnv::getProductionURI(407'/herald/transcript/'.$blocking_xscript->getID().'/')));408}409}410411public function loadViewerProjectPHIDsForHerald() {412// This just caches the viewer's projects so we don't need to load them413// over and over again when applying Herald rules.414if ($this->heraldViewerProjects === null) {415$this->heraldViewerProjects = id(new PhabricatorProjectQuery())416->setViewer($this->getViewer())417->withMemberPHIDs(array($this->getViewer()->getPHID()))418->execute();419}420421return mpull($this->heraldViewerProjects, 'getPHID');422}423424425/* -( Git )---------------------------------------------------------------- */426427428private function findGitRefUpdates() {429$ref_updates = array();430431// First, parse stdin, which lists all the ref changes. The input looks432// like this:433//434// <old hash> <new hash> <ref>435436$stdin = $this->getStdin();437$lines = phutil_split_lines($stdin, $retain_endings = false);438foreach ($lines as $line) {439$parts = explode(' ', $line, 3);440if (count($parts) != 3) {441throw new Exception(pht('Expected "old new ref", got "%s".', $line));442}443444$ref_old = $parts[0];445$ref_new = $parts[1];446$ref_raw = $parts[2];447448if (preg_match('(^refs/heads/)', $ref_raw)) {449$ref_type = PhabricatorRepositoryPushLog::REFTYPE_BRANCH;450$ref_raw = substr($ref_raw, strlen('refs/heads/'));451} else if (preg_match('(^refs/tags/)', $ref_raw)) {452$ref_type = PhabricatorRepositoryPushLog::REFTYPE_TAG;453$ref_raw = substr($ref_raw, strlen('refs/tags/'));454} else {455$ref_type = PhabricatorRepositoryPushLog::REFTYPE_REF;456}457458$ref_update = $this->newPushLog()459->setRefType($ref_type)460->setRefName($ref_raw)461->setRefOld($ref_old)462->setRefNew($ref_new);463464$ref_updates[] = $ref_update;465}466467$this->findGitMergeBases($ref_updates);468$this->findGitChangeFlags($ref_updates);469470return $ref_updates;471}472473474private function findGitMergeBases(array $ref_updates) {475assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');476477$futures = array();478foreach ($ref_updates as $key => $ref_update) {479// If the old hash is "00000...", the ref is being created (either a new480// branch, or a new tag). If the new hash is "00000...", the ref is being481// deleted. If both are nonempty, the ref is being updated. For updates,482// we'll figure out the `merge-base` of the old and new objects here. This483// lets us reject non-FF changes cheaply; later, we'll figure out exactly484// which commits are new.485$ref_old = $ref_update->getRefOld();486$ref_new = $ref_update->getRefNew();487488if (($ref_old === self::EMPTY_HASH) ||489($ref_new === self::EMPTY_HASH)) {490continue;491}492493$futures[$key] = $this->getRepository()->getLocalCommandFuture(494'merge-base %s %s',495$ref_old,496$ref_new);497}498499$futures = id(new FutureIterator($futures))500->limit(8);501foreach ($futures as $key => $future) {502503// If 'old' and 'new' have no common ancestors (for example, a force push504// which completely rewrites a ref), `git merge-base` will exit with505// an error and no output. It would be nice to find a positive test506// for this instead, but I couldn't immediately come up with one. See507// T4224. Assume this means there are no ancestors.508509list($err, $stdout) = $future->resolve();510511if ($err) {512$merge_base = null;513} else {514$merge_base = rtrim($stdout, "\n");515}516517$ref_update = $ref_updates[$key];518$ref_update->setMergeBase($merge_base);519}520521return $ref_updates;522}523524525private function findGitChangeFlags(array $ref_updates) {526assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');527528foreach ($ref_updates as $key => $ref_update) {529$ref_old = $ref_update->getRefOld();530$ref_new = $ref_update->getRefNew();531$ref_type = $ref_update->getRefType();532533$ref_flags = 0;534$dangerous = null;535536if (($ref_old === self::EMPTY_HASH) && ($ref_new === self::EMPTY_HASH)) {537// This happens if you try to delete a tag or branch which does not538// exist by pushing directly to the ref. Git will warn about it but539// allow it. Just call it a delete, without flagging it as dangerous.540$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;541} else if ($ref_old === self::EMPTY_HASH) {542$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;543} else if ($ref_new === self::EMPTY_HASH) {544$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;545if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) {546$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;547$dangerous = pht(548"The change you're attempting to push deletes the branch '%s'.",549$ref_update->getRefName());550}551} else {552$merge_base = $ref_update->getMergeBase();553if ($merge_base == $ref_old) {554// This is a fast-forward update to an existing branch.555// These are safe.556$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;557} else {558$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE;559560// For now, we don't consider deleting or moving tags to be a561// "dangerous" update. It's way harder to get wrong and should be easy562// to recover from once we have better logging. Only add the dangerous563// flag if this ref is a branch.564565if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) {566$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;567568$dangerous = pht(569"The change you're attempting to push updates the branch '%s' ".570"from '%s' to '%s', but this is not a fast-forward. Pushes ".571"which rewrite published branch history are dangerous.",572$ref_update->getRefName(),573$ref_update->getRefOldShort(),574$ref_update->getRefNewShort());575}576}577}578579$ref_update->setChangeFlags($ref_flags);580if ($dangerous !== null) {581$ref_update->attachDangerousChangeDescription($dangerous);582}583}584585return $ref_updates;586}587588589private function findGitContentUpdates(array $ref_updates) {590$flag_delete = PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;591592$futures = array();593foreach ($ref_updates as $key => $ref_update) {594if ($ref_update->hasChangeFlags($flag_delete)) {595// Deleting a branch or tag can never create any new commits.596continue;597}598599// NOTE: This piece of magic finds all new commits, by walking backward600// from the new value to the value of *any* existing ref in the601// repository. Particularly, this will cover the cases of a new branch, a602// completely moved tag, etc.603$futures[$key] = $this->getRepository()->getLocalCommandFuture(604'log %s %s --not --all --',605'--format=%H',606gitsprintf('%s', $ref_update->getRefNew()));607}608609$content_updates = array();610$futures = id(new FutureIterator($futures))611->limit(8);612foreach ($futures as $key => $future) {613list($stdout) = $future->resolvex();614615if (!strlen(trim($stdout))) {616// This change doesn't have any new commits. One common case of this617// is creating a new tag which points at an existing commit.618continue;619}620621$commits = phutil_split_lines($stdout, $retain_newlines = false);622623// If we're looking at a branch, mark all of the new commits as on that624// branch. It's only possible for these commits to be on updated branches,625// since any other branch heads are necessarily behind them.626$branch_name = null;627$ref_update = $ref_updates[$key];628$type_branch = PhabricatorRepositoryPushLog::REFTYPE_BRANCH;629if ($ref_update->getRefType() == $type_branch) {630$branch_name = $ref_update->getRefName();631}632633foreach ($commits as $commit) {634if ($branch_name) {635$this->gitCommits[$commit][] = $branch_name;636}637$content_updates[$commit] = $this->newPushLog()638->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)639->setRefNew($commit)640->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD);641}642}643644return $content_updates;645}646647/* -( Custom )------------------------------------------------------------- */648649private function applyCustomHooks(array $updates) {650$args = $this->getOriginalArgv();651$stdin = $this->getStdin();652$console = PhutilConsole::getConsole();653654$env = array(655self::ENV_REPOSITORY => $this->getRepository()->getPHID(),656self::ENV_USER => $this->getViewer()->getUsername(),657self::ENV_REQUEST => $this->getRequestIdentifier(),658self::ENV_REMOTE_PROTOCOL => $this->getRemoteProtocol(),659self::ENV_REMOTE_ADDRESS => $this->getRemoteAddress(),660);661662$repository = $this->getRepository();663664$env += $repository->getPassthroughEnvironmentalVariables();665666$directories = $repository->getHookDirectories();667foreach ($directories as $directory) {668$hooks = $this->getExecutablesInDirectory($directory);669sort($hooks);670foreach ($hooks as $hook) {671// NOTE: We're explicitly running the hooks in sequential order to672// make this more predictable.673$future = id(new ExecFuture('%s %Ls', $hook, $args))674->setEnv($env, $wipe_process_env = false)675->write($stdin);676677list($err, $stdout, $stderr) = $future->resolve();678if (!$err) {679// This hook ran OK, but echo its output in case there was something680// informative.681$console->writeOut('%s', $stdout);682$console->writeErr('%s', $stderr);683continue;684}685686$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_EXTERNAL;687$this->rejectDetails = basename($hook);688689throw new DiffusionCommitHookRejectException(690pht(691"This push was rejected by custom hook script '%s':\n\n%s%s",692basename($hook),693$stdout,694$stderr));695}696}697}698699private function getExecutablesInDirectory($directory) {700$executables = array();701702if (!Filesystem::pathExists($directory)) {703return $executables;704}705706foreach (Filesystem::listDirectory($directory) as $path) {707$full_path = $directory.DIRECTORY_SEPARATOR.$path;708if (!is_executable($full_path)) {709// Don't include non-executable files.710continue;711}712713if (basename($full_path) == 'README') {714// Don't include README, even if it is marked as executable. It almost715// certainly got caught in the crossfire of a sweeping `chmod`, since716// users do this with some frequency.717continue;718}719720$executables[] = $full_path;721}722723return $executables;724}725726727/* -( Mercurial )---------------------------------------------------------- */728729730private function findMercurialRefUpdates() {731$hook = $this->getMercurialHook();732switch ($hook) {733case 'pretxnchangegroup':734return $this->findMercurialChangegroupRefUpdates();735case 'prepushkey':736return $this->findMercurialPushKeyRefUpdates();737default:738throw new Exception(pht('Unrecognized hook "%s"!', $hook));739}740}741742private function findMercurialChangegroupRefUpdates() {743$hg_node = getenv('HG_NODE');744if (!$hg_node) {745throw new Exception(746pht(747'Expected %s in environment!',748'HG_NODE'));749}750751// NOTE: We need to make sure this is passed to subprocesses, or they won't752// be able to see new commits. Mercurial uses this as a marker to determine753// whether the pending changes are visible or not.754$_ENV['HG_PENDING'] = getenv('HG_PENDING');755$repository = $this->getRepository();756757$futures = array();758759foreach (array('old', 'new') as $key) {760$futures[$key] = $repository->getLocalCommandFuture(761'heads --template %s',762'{node}\1{branch}\2');763}764// Wipe HG_PENDING out of the old environment so we see the pre-commit765// state of the repository.766$futures['old']->updateEnv('HG_PENDING', null);767768$futures['commits'] = $repository->getLocalCommandFuture(769'log --rev %s --template %s',770hgsprintf('%s:%s', $hg_node, 'tip'),771'{node}\1{branch}\2');772773// Resolve all of the futures now. We don't need the 'commits' future yet,774// but it simplifies the logic to just get it out of the way.775foreach (new FutureIterator($futures) as $future) {776$future->resolve();777}778779list($commit_raw) = $futures['commits']->resolvex();780$commit_map = $this->parseMercurialCommits($commit_raw);781$this->mercurialCommits = $commit_map;782783// NOTE: `hg heads` exits with an error code and no output if the repository784// has no heads. Most commonly this happens on a new repository. We know785// we can run `hg` successfully since the `hg log` above didn't error, so786// just ignore the error code.787788list($err, $old_raw) = $futures['old']->resolve();789$old_refs = $this->parseMercurialHeads($old_raw);790791list($err, $new_raw) = $futures['new']->resolve();792$new_refs = $this->parseMercurialHeads($new_raw);793794$all_refs = array_keys($old_refs + $new_refs);795796$ref_updates = array();797foreach ($all_refs as $ref) {798$old_heads = idx($old_refs, $ref, array());799$new_heads = idx($new_refs, $ref, array());800801sort($old_heads);802sort($new_heads);803804if (!$old_heads && !$new_heads) {805// This should never be possible, as it makes no sense. Explode.806throw new Exception(807pht(808'Mercurial repository has no new or old heads for branch "%s" '.809'after push. This makes no sense; rejecting change.',810$ref));811}812813if ($old_heads === $new_heads) {814// No changes to this branch, so skip it.815continue;816}817818$stray_heads = array();819$head_map = array();820821if ($old_heads && !$new_heads) {822// This is a branch deletion with "--close-branch".823foreach ($old_heads as $old_head) {824$head_map[$old_head] = array(self::EMPTY_HASH);825}826} else if (count($old_heads) > 1) {827// HORRIBLE: In Mercurial, branches can have multiple heads. If the828// old branch had multiple heads, we need to figure out which new829// heads descend from which old heads, so we can tell whether you're830// actively creating new heads (dangerous) or just working in a831// repository that's already full of garbage (strongly discouraged but832// not as inherently dangerous). These cases should be very uncommon.833834// NOTE: We're only looking for heads on the same branch. The old835// tip of the branch may be the branchpoint for other branches, but that836// is OK.837838$dfutures = array();839foreach ($old_heads as $old_head) {840$dfutures[$old_head] = $repository->getLocalCommandFuture(841'log --branch %s --rev %s --template %s',842$ref,843hgsprintf('(descendants(%s) and head())', $old_head),844'{node}\1');845}846847foreach (new FutureIterator($dfutures) as $future_head => $dfuture) {848list($stdout) = $dfuture->resolvex();849$descendant_heads = array_filter(explode("\1", $stdout));850if ($descendant_heads) {851// This old head has at least one descendant in the push.852$head_map[$future_head] = $descendant_heads;853} else {854// This old head has no descendants, so it is being deleted.855$head_map[$future_head] = array(self::EMPTY_HASH);856}857}858859// Now, find all the new stray heads this push creates, if any. These860// are new heads which do not descend from the old heads.861$seen = array_fuse(array_mergev($head_map));862foreach ($new_heads as $new_head) {863if ($new_head === self::EMPTY_HASH) {864// If a branch head is being deleted, don't insert it as an add.865continue;866}867if (empty($seen[$new_head])) {868$head_map[self::EMPTY_HASH][] = $new_head;869}870}871} else if ($old_heads) {872$head_map[head($old_heads)] = $new_heads;873} else {874$head_map[self::EMPTY_HASH] = $new_heads;875}876877foreach ($head_map as $old_head => $child_heads) {878foreach ($child_heads as $new_head) {879if ($new_head === $old_head) {880continue;881}882883$ref_flags = 0;884$dangerous = null;885if ($old_head == self::EMPTY_HASH) {886$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;887} else {888$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;889}890891892$deletes_existing_head = ($new_head == self::EMPTY_HASH);893$splits_existing_head = (count($child_heads) > 1);894$creates_duplicate_head = ($old_head == self::EMPTY_HASH) &&895(count($head_map) > 1);896897if ($splits_existing_head || $creates_duplicate_head) {898$readable_child_heads = array();899foreach ($child_heads as $child_head) {900$readable_child_heads[] = substr($child_head, 0, 12);901}902903$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;904905if ($splits_existing_head) {906// We're splitting an existing head into two or more heads.907// This is dangerous, and a super bad idea. Note that we're only908// raising this if you're actively splitting a branch head. If a909// head split in the past, we don't consider appends to it910// to be dangerous.911$dangerous = pht(912"The change you're attempting to push splits the head of ".913"branch '%s' into multiple heads: %s. This is inadvisable ".914"and dangerous.",915$ref,916implode(', ', $readable_child_heads));917} else {918// We're adding a second (or more) head to a branch. The new919// head is not a descendant of any old head.920$dangerous = pht(921"The change you're attempting to push creates new, divergent ".922"heads for the branch '%s': %s. This is inadvisable and ".923"dangerous.",924$ref,925implode(', ', $readable_child_heads));926}927}928929if ($deletes_existing_head) {930// TODO: Somewhere in here we should be setting CHANGEFLAG_REWRITE931// if we are also creating at least one other head to replace932// this one.933934// NOTE: In Git, this is a dangerous change, but it is not dangerous935// in Mercurial. Mercurial branches are version controlled, and936// Mercurial does not prompt you for any special flags when pushing937// a `--close-branch` commit by default.938939$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;940}941942$ref_update = $this->newPushLog()943->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BRANCH)944->setRefName($ref)945->setRefOld($old_head)946->setRefNew($new_head)947->setChangeFlags($ref_flags);948949if ($dangerous !== null) {950$ref_update->attachDangerousChangeDescription($dangerous);951}952953$ref_updates[] = $ref_update;954}955}956}957958return $ref_updates;959}960961private function findMercurialPushKeyRefUpdates() {962$key_namespace = getenv('HG_NAMESPACE');963964if ($key_namespace === 'phases') {965// Mercurial changes commit phases as part of normal push operations. We966// just ignore these, as they don't seem to represent anything967// interesting.968return array();969}970971$key_name = getenv('HG_KEY');972973$key_old = getenv('HG_OLD');974if (!strlen($key_old)) {975$key_old = null;976}977978$key_new = getenv('HG_NEW');979if (!strlen($key_new)) {980$key_new = null;981}982983if ($key_namespace !== 'bookmarks') {984throw new Exception(985pht(986"Unknown Mercurial key namespace '%s', with key '%s' (%s -> %s). ".987"Rejecting push.",988$key_namespace,989$key_name,990coalesce($key_old, pht('null')),991coalesce($key_new, pht('null'))));992}993994if ($key_old === $key_new) {995// We get a callback when the bookmark doesn't change. Just ignore this,996// as it's a no-op.997return array();998}9991000$ref_flags = 0;1001$merge_base = null;1002if ($key_old === null) {1003$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;1004} else if ($key_new === null) {1005$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;1006} else {1007list($merge_base_raw) = $this->getRepository()->execxLocalCommand(1008'log --template %s --rev %s',1009'{node}',1010hgsprintf('ancestor(%s, %s)', $key_old, $key_new));10111012if (strlen(trim($merge_base_raw))) {1013$merge_base = trim($merge_base_raw);1014}10151016if ($merge_base && ($merge_base === $key_old)) {1017$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;1018} else {1019$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE;1020}1021}10221023$ref_update = $this->newPushLog()1024->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BOOKMARK)1025->setRefName($key_name)1026->setRefOld(coalesce($key_old, self::EMPTY_HASH))1027->setRefNew(coalesce($key_new, self::EMPTY_HASH))1028->setChangeFlags($ref_flags);10291030return array($ref_update);1031}10321033private function findMercurialContentUpdates(array $ref_updates) {1034$content_updates = array();10351036foreach ($this->mercurialCommits as $commit => $branches) {1037$content_updates[$commit] = $this->newPushLog()1038->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)1039->setRefNew($commit)1040->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD);1041}10421043return $content_updates;1044}10451046private function parseMercurialCommits($raw) {1047$commits_lines = explode("\2", $raw);1048$commits_lines = array_filter($commits_lines);1049$commit_map = array();1050foreach ($commits_lines as $commit_line) {1051list($node, $branch) = explode("\1", $commit_line);1052$commit_map[$node] = array($branch);1053}10541055return $commit_map;1056}10571058private function parseMercurialHeads($raw) {1059$heads_map = $this->parseMercurialCommits($raw);10601061$heads = array();1062foreach ($heads_map as $commit => $branches) {1063foreach ($branches as $branch) {1064$heads[$branch][] = $commit;1065}1066}10671068return $heads;1069}107010711072/* -( Subversion )--------------------------------------------------------- */107310741075private function findSubversionRefUpdates() {1076// Subversion doesn't have any kind of mutable ref metadata.1077return array();1078}10791080private function findSubversionContentUpdates(array $ref_updates) {1081list($youngest) = execx(1082'svnlook youngest %s',1083$this->subversionRepository);1084$ref_new = (int)$youngest + 1;10851086$ref_flags = 0;1087$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;1088$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;10891090$ref_content = $this->newPushLog()1091->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)1092->setRefNew($ref_new)1093->setChangeFlags($ref_flags);10941095return array($ref_content);1096}109710981099/* -( Internals )---------------------------------------------------------- */110011011102private function newPushLog() {1103// NOTE: We generate PHIDs up front so the Herald transcripts can pick them1104// up.1105$phid = id(new PhabricatorRepositoryPushLog())->generatePHID();11061107$device = AlmanacKeys::getLiveDevice();1108if ($device) {1109$device_phid = $device->getPHID();1110} else {1111$device_phid = null;1112}11131114return PhabricatorRepositoryPushLog::initializeNewLog($this->getViewer())1115->setPHID($phid)1116->setDevicePHID($device_phid)1117->setRepositoryPHID($this->getRepository()->getPHID())1118->attachRepository($this->getRepository())1119->setEpoch(PhabricatorTime::getNow());1120}11211122private function newPushEvent() {1123$viewer = $this->getViewer();11241125$hook_start = $this->getStartTime();11261127$event = PhabricatorRepositoryPushEvent::initializeNewEvent($viewer)1128->setRepositoryPHID($this->getRepository()->getPHID())1129->setRemoteAddress($this->getRemoteAddress())1130->setRemoteProtocol($this->getRemoteProtocol())1131->setEpoch(PhabricatorTime::getNow())1132->setHookWait(phutil_microseconds_since($hook_start));11331134$identifier = $this->getRequestIdentifier();1135if ($identifier !== null && strlen($identifier)) {1136$event->setRequestIdentifier($identifier);1137}11381139return $event;1140}11411142private function rejectEnormousChanges(array $content_updates) {1143$repository = $this->getRepository();1144if ($repository->shouldAllowEnormousChanges()) {1145return;1146}11471148// See T13142. Don't cache more than 64MB of changesets. For normal small1149// pushes, caching everything here can let us hit the cache from Herald if1150// we need to run content rules, which speeds things up a bit. For large1151// pushes, we may not be able to hold everything in memory.1152$cache_limit = 1024 * 1024 * 64;11531154foreach ($content_updates as $update) {1155$identifier = $update->getRefNew();1156try {1157$info = $this->loadChangesetsForCommit($identifier);1158list($changesets, $size) = $info;11591160if ($this->changesetsSize + $size <= $cache_limit) {1161$this->changesets[$identifier] = $changesets;1162$this->changesetsSize += $size;1163}1164} catch (Exception $ex) {1165$this->changesets[$identifier] = $ex;11661167$message = pht(1168'ENORMOUS CHANGE'.1169"\n".1170'Enormous change protection is enabled for this repository, but '.1171'you are pushing an enormous change ("%s"). Edit the repository '.1172'configuration before making enormous changes.'.1173"\n\n".1174"Content Exception: %s",1175$identifier,1176$ex->getMessage());11771178throw new DiffusionCommitHookRejectException($message);1179}1180}1181}11821183private function loadChangesetsForCommit($identifier) {1184$byte_limit = HeraldCommitAdapter::getEnormousByteLimit();1185$time_limit = HeraldCommitAdapter::getEnormousTimeLimit();11861187$vcs = $this->getRepository()->getVersionControlSystem();1188switch ($vcs) {1189case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:1190case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:1191// For git and hg, we can use normal commands.1192$drequest = DiffusionRequest::newFromDictionary(1193array(1194'repository' => $this->getRepository(),1195'user' => $this->getViewer(),1196'commit' => $identifier,1197));11981199$raw_diff = DiffusionRawDiffQuery::newFromDiffusionRequest($drequest)1200->setTimeout($time_limit)1201->setByteLimit($byte_limit)1202->setLinesOfContext(0)1203->executeInline();1204break;1205case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:1206// TODO: This diff has 3 lines of context, which produces slightly1207// incorrect "added file content" and "removed file content" results.1208// This may also choke on binaries, but "svnlook diff" does not support1209// the "--diff-cmd" flag.12101211// For subversion, we need to use `svnlook`.1212$future = new ExecFuture(1213'svnlook diff -t %s %s',1214$this->subversionTransaction,1215$this->subversionRepository);12161217$future->setTimeout($time_limit);1218$future->setStdoutSizeLimit($byte_limit);1219$future->setStderrSizeLimit($byte_limit);12201221list($raw_diff) = $future->resolvex();1222break;1223default:1224throw new Exception(pht("Unknown VCS '%s!'", $vcs));1225}12261227if (strlen($raw_diff) >= $byte_limit) {1228throw new Exception(1229pht(1230'The raw text of this change ("%s") is enormous (larger than %s '.1231'bytes).',1232$identifier,1233new PhutilNumber($byte_limit)));1234}12351236if (!strlen($raw_diff)) {1237// If the commit is actually empty, just return no changesets.1238return array(array(), 0);1239}12401241$parser = new ArcanistDiffParser();1242$changes = $parser->parseDiff($raw_diff);1243$diff = DifferentialDiff::newEphemeralFromRawChanges(1244$changes);12451246$changesets = $diff->getChangesets();1247$size = strlen($raw_diff);12481249return array($changesets, $size);1250}12511252public function getChangesetsForCommit($identifier) {1253if (isset($this->changesets[$identifier])) {1254$cached = $this->changesets[$identifier];12551256if ($cached instanceof Exception) {1257throw $cached;1258}12591260return $cached;1261}12621263$info = $this->loadChangesetsForCommit($identifier);1264list($changesets, $size) = $info;1265return $changesets;1266}12671268private function rejectOversizedFiles(array $content_updates) {1269$repository = $this->getRepository();12701271$limit = $repository->getFilesizeLimit();1272if (!$limit) {1273return;1274}12751276foreach ($content_updates as $update) {1277$identifier = $update->getRefNew();12781279$sizes = $this->getFileSizesForCommit($identifier);12801281foreach ($sizes as $path => $size) {1282if ($size <= $limit) {1283continue;1284}12851286$message = pht(1287'OVERSIZED FILE'.1288"\n".1289'This repository ("%s") is configured with a maximum individual '.1290'file size limit, but you are pushing a change ("%s") which causes '.1291'the size of a file ("%s") to exceed the limit. The commit makes '.1292'the file %s bytes long, but the limit for this repository is '.1293'%s bytes.',1294$repository->getDisplayName(),1295$identifier,1296$path,1297new PhutilNumber($size),1298new PhutilNumber($limit));12991300throw new DiffusionCommitHookRejectException($message);1301}1302}1303}13041305private function rejectCommitsAffectingTooManyPaths(array $content_updates) {1306$repository = $this->getRepository();13071308$limit = $repository->getTouchLimit();1309if (!$limit) {1310return;1311}13121313foreach ($content_updates as $update) {1314$identifier = $update->getRefNew();13151316$sizes = $this->getFileSizesForCommit($identifier);1317if (count($sizes) > $limit) {1318$message = pht(1319'COMMIT AFFECTS TOO MANY PATHS'.1320"\n".1321'This repository ("%s") is configured with a touched files limit '.1322'that caps the maximum number of paths any single commit may '.1323'affect. You are pushing a change ("%s") which exceeds this '.1324'limit: it affects %s paths, but the largest number of paths any '.1325'commit may affect is %s paths.',1326$repository->getDisplayName(),1327$identifier,1328phutil_count($sizes),1329new PhutilNumber($limit));13301331throw new DiffusionCommitHookRejectException($message);1332}1333}1334}13351336public function getFileSizesForCommit($identifier) {1337if (!isset($this->filesizeCache[$identifier])) {1338$file_sizes = $this->loadFileSizesForCommit($identifier);1339$this->filesizeCache[$identifier] = $file_sizes;1340}13411342return $this->filesizeCache[$identifier];1343}13441345private function loadFileSizesForCommit($identifier) {1346$repository = $this->getRepository();13471348return id(new DiffusionLowLevelFilesizeQuery())1349->setRepository($repository)1350->withIdentifier($identifier)1351->execute();1352}13531354public function loadCommitRefForCommit($identifier) {1355$repository = $this->getRepository();1356$vcs = $repository->getVersionControlSystem();1357switch ($vcs) {1358case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:1359case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:1360return id(new DiffusionLowLevelCommitQuery())1361->setRepository($repository)1362->withIdentifier($identifier)1363->execute();1364case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:1365// For subversion, we need to use `svnlook`.1366list($message) = execx(1367'svnlook log -t %s %s',1368$this->subversionTransaction,1369$this->subversionRepository);13701371return id(new DiffusionCommitRef())1372->setMessage($message);1373break;1374default:1375throw new Exception(pht("Unknown VCS '%s!'", $vcs));1376}1377}13781379public function loadBranches($identifier) {1380$repository = $this->getRepository();1381$vcs = $repository->getVersionControlSystem();1382switch ($vcs) {1383case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:1384return idx($this->gitCommits, $identifier, array());1385case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:1386// NOTE: This will be "the branch the commit was made to", not1387// "a list of all branch heads which descend from the commit".1388// This is consistent with Mercurial, but possibly confusing.1389return idx($this->mercurialCommits, $identifier, array());1390case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:1391// Subversion doesn't have branches.1392return array();1393}1394}13951396private function loadCommitInfoForWorker(array $all_updates) {1397$type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT;13981399$map = array();1400foreach ($all_updates as $update) {1401if ($update->getRefType() != $type_commit) {1402continue;1403}1404$map[$update->getRefNew()] = array();1405}14061407foreach ($map as $identifier => $info) {1408$ref = $this->loadCommitRefForCommit($identifier);1409$map[$identifier] += array(1410'summary' => $ref->getSummary(),1411'branches' => $this->loadBranches($identifier),1412);1413}14141415return $map;1416}14171418private function isInitialImport(array $all_updates) {1419$repository = $this->getRepository();14201421$vcs = $repository->getVersionControlSystem();1422switch ($vcs) {1423case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:1424// There is no meaningful way to import history into Subversion by1425// pushing.1426return false;1427default:1428break;1429}14301431// Now, apply a heuristic to guess whether this is a normal commit or1432// an initial import. We guess something is an initial import if:1433//1434// - the repository is currently empty; and1435// - it pushes more than 7 commits at once.1436//1437// The number "7" is chosen arbitrarily as seeming reasonable. We could1438// also look at author data (do the commits come from multiple different1439// authors?) and commit date data (is the oldest commit more than 48 hours1440// old), but we don't have immediate access to those and this simple1441// heuristic might be good enough.14421443$commit_count = 0;1444$type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT;1445foreach ($all_updates as $update) {1446if ($update->getRefType() != $type_commit) {1447continue;1448}1449$commit_count++;1450}14511452if ($commit_count <= PhabricatorRepository::IMPORT_THRESHOLD) {1453// If this pushes a very small number of commits, assume it's an1454// initial commit or stack of a few initial commits.1455return false;1456}14571458$any_commits = id(new DiffusionCommitQuery())1459->setViewer($this->getViewer())1460->withRepository($repository)1461->setLimit(1)1462->execute();14631464if ($any_commits) {1465// If the repository already has commits, this isn't an import.1466return false;1467}14681469return true;1470}14711472}147314741475