4 * @task discover Discovering Repositories
5 * @task svn Discovering Subversion Repositories
6 * @task git Discovering Git Repositories
7 * @task hg Discovering Mercurial Repositories
8 * @task internal Internals
10 final class PhabricatorRepositoryDiscoveryEngine
11 extends PhabricatorRepositoryEngine
{
14 private $commitCache = array();
15 private $workingSet = array();
17 const MAX_COMMIT_CACHE_SIZE
= 65535;
20 /* -( Discovering Repositories )------------------------------------------- */
23 public function setRepairMode($repair_mode) {
24 $this->repairMode
= $repair_mode;
29 public function getRepairMode() {
30 return $this->repairMode
;
37 public function discoverCommits() {
38 $repository = $this->getRepository();
40 $lock = $this->newRepositoryLock($repository, 'repo.look', false);
44 } catch (PhutilLockException
$ex) {
45 throw new DiffusionDaemonLockException(
47 'Another process is currently discovering repository "%s", '.
48 'skipping discovery.',
49 $repository->getDisplayName()));
53 $result = $this->discoverCommitsWithLock();
54 } catch (Exception
$ex) {
64 private function discoverCommitsWithLock() {
65 $repository = $this->getRepository();
66 $viewer = $this->getViewer();
68 $vcs = $repository->getVersionControlSystem();
70 case PhabricatorRepositoryType
::REPOSITORY_TYPE_SVN
:
71 $refs = $this->discoverSubversionCommits();
73 case PhabricatorRepositoryType
::REPOSITORY_TYPE_MERCURIAL
:
74 $refs = $this->discoverMercurialCommits();
76 case PhabricatorRepositoryType
::REPOSITORY_TYPE_GIT
:
77 $refs = $this->discoverGitCommits();
80 throw new Exception(pht("Unknown VCS '%s'!", $vcs));
83 if ($this->isInitialImport($refs)) {
86 'Discovered more than %s commit(s) in an empty repository, '.
87 'marking repository as importing.',
88 new PhutilNumber(PhabricatorRepository
::IMPORT_THRESHOLD
)));
90 $repository->markImporting();
93 // Clear the working set cache.
94 $this->workingSet
= array();
96 $task_priority = $this->getImportTaskPriority($repository, $refs);
98 // Record discovered commits and mark them in the cache.
99 foreach ($refs as $ref) {
102 $ref->getIdentifier(),
104 $ref->getIsPermanent(),
108 $this->commitCache
[$ref->getIdentifier()] = true;
111 $this->markUnreachableCommits($repository);
113 $version = $this->getObservedVersion($repository);
114 if ($version !== null) {
115 id(new DiffusionRepositoryClusterEngine())
117 ->setRepository($repository)
118 ->synchronizeWorkingCopyAfterDiscovery($version);
125 /* -( Discovering Git Repositories )--------------------------------------- */
131 private function discoverGitCommits() {
132 $repository = $this->getRepository();
133 $publisher = $repository->newPublisher();
135 $heads = id(new DiffusionLowLevelGitRefQuery())
136 ->setRepository($repository)
140 // This repository has no heads at all, so we don't need to do
141 // anything. Generally, this means the repository is empty.
147 'Discovering commits in repository "%s".',
148 $repository->getDisplayName()));
150 $ref_lists = array();
152 $head_groups = $this->getRefGroupsForDiscovery($heads);
153 foreach ($head_groups as $head_group) {
155 $group_identifiers = mpull($head_group, 'getCommitIdentifier');
156 $group_identifiers = array_fuse($group_identifiers);
157 $this->fillCommitCache($group_identifiers);
159 foreach ($head_group as $ref) {
160 $name = $ref->getShortName();
161 $commit = $ref->getCommitIdentifier();
165 'Examining "%s" (%s) at "%s".',
170 if (!$repository->shouldTrackRef($ref)) {
171 $this->log(pht('Skipping, ref is untracked.'));
175 if ($this->isKnownCommit($commit)) {
176 $this->log(pht('Skipping, HEAD is known.'));
180 // In Git, it's possible to tag anything. We just skip tags that don't
181 // point to a commit. See T11301.
182 $fields = $ref->getRawFields();
183 $ref_type = idx($fields, 'objecttype');
184 $tag_type = idx($fields, '*objecttype');
185 if ($ref_type != 'commit' && $tag_type != 'commit') {
186 $this->log(pht('Skipping, this is not a commit.'));
190 $this->log(pht('Looking for new commits.'));
192 $head_refs = $this->discoverStreamAncestry(
193 new PhabricatorGitGraphStream($repository, $commit),
195 $publisher->isPermanentRef($ref));
197 $this->didDiscoverRefs($head_refs);
199 $ref_lists[] = $head_refs;
203 $refs = array_mergev($ref_lists);
211 private function getRefGroupsForDiscovery(array $heads) {
212 $heads = $this->sortRefs($heads);
214 // See T13593. We hold a commit cache with a fixed maximum size. Split the
215 // refs into chunks no larger than the cache size, so we don't overflow the
216 // cache when testing them.
218 $array_iterator = new ArrayIterator($heads);
220 $chunk_iterator = new PhutilChunkedIterator(
222 self
::MAX_COMMIT_CACHE_SIZE
);
224 return $chunk_iterator;
228 /* -( Discovering Subversion Repositories )-------------------------------- */
234 private function discoverSubversionCommits() {
235 $repository = $this->getRepository();
237 if (!$repository->isHosted()) {
238 $this->verifySubversionRoot($repository);
245 // Find all the unknown commits on this path. Note that we permit
246 // importing an SVN subdirectory rather than the entire repository, so
247 // commits may be nonsequential.
249 if ($upper_bound === null) {
252 $at_rev = ($upper_bound - 1);
256 list($xml, $stderr) = $repository->execxRemoteCommand(
257 'log --xml --quiet --limit %d %s',
259 $repository->getSubversionBaseURI($at_rev));
260 } catch (CommandException
$ex) {
261 $stderr = $ex->getStderr();
262 if (preg_match('/(path|File) not found/', $stderr)) {
263 // We've gone all the way back through history and this path was not
264 // affected by earlier commits.
270 $xml = phutil_utf8ize($xml);
271 $log = new SimpleXMLElement($xml);
272 foreach ($log->logentry
as $entry) {
273 $identifier = (int)$entry['revision'];
274 $epoch = (int)strtotime((string)$entry->date
[0]);
275 $refs[$identifier] = id(new PhabricatorRepositoryCommitRef())
276 ->setIdentifier($identifier)
278 ->setIsPermanent(true);
280 if ($upper_bound === null) {
281 $upper_bound = $identifier;
283 $upper_bound = min($upper_bound, $identifier);
287 // Discover 2, 4, 8, ... 256 logs at a time. This allows us to initially
288 // import large repositories fairly quickly, while pulling only as much
289 // data as we need in the common case (when we've already imported the
290 // repository and are just grabbing one commit at a time).
291 $limit = min($limit * 2, 256);
293 } while ($upper_bound > 1 && !$this->isKnownCommit($upper_bound));
296 while ($refs && $this->isKnownCommit(last($refs)->getIdentifier())) {
299 $refs = array_reverse($refs);
301 $this->didDiscoverRefs($refs);
307 private function verifySubversionRoot(PhabricatorRepository
$repository) {
308 list($xml) = $repository->execxRemoteCommand(
310 $repository->getSubversionPathURI());
312 $xml = phutil_utf8ize($xml);
313 $xml = new SimpleXMLElement($xml);
315 $remote_root = (string)($xml->entry
[0]->repository
[0]->root
[0]);
316 $expect_root = $repository->getSubversionPathURI();
318 $normal_type_svn = ArcanistRepositoryURINormalizer
::TYPE_SVN
;
320 $remote_normal = id(new ArcanistRepositoryURINormalizer(
322 $remote_root))->getNormalizedPath();
324 $expect_normal = id(new ArcanistRepositoryURINormalizer(
326 $expect_root))->getNormalizedPath();
328 if ($remote_normal != $expect_normal) {
331 'Repository "%s" does not have a correctly configured remote URI. '.
332 'The remote URI for a Subversion repository MUST point at the '.
333 'repository root. The root for this repository is "%s", but the '.
334 'configured URI is "%s". To resolve this error, set the remote URI '.
335 'to point at the repository root. If you want to import only part '.
336 'of a Subversion repository, use the "Import Only" option.',
337 $repository->getDisplayName(),
344 /* -( Discovering Mercurial Repositories )--------------------------------- */
350 private function discoverMercurialCommits() {
351 $repository = $this->getRepository();
353 $branches = id(new DiffusionLowLevelMercurialBranchesQuery())
354 ->setRepository($repository)
357 $this->fillCommitCache(mpull($branches, 'getCommitIdentifier'));
360 foreach ($branches as $branch) {
361 // NOTE: Mercurial branches may have multiple heads, so the names may
363 $name = $branch->getShortName();
364 $commit = $branch->getCommitIdentifier();
366 $this->log(pht('Examining branch "%s" head "%s".', $name, $commit));
367 if (!$repository->shouldTrackBranch($name)) {
368 $this->log(pht('Skipping, branch is untracked.'));
372 if ($this->isKnownCommit($commit)) {
373 $this->log(pht('Skipping, this head is a known commit.'));
377 $this->log(pht('Looking for new commits.'));
379 $branch_refs = $this->discoverStreamAncestry(
380 new PhabricatorMercurialGraphStream($repository, $commit),
382 $is_permanent = true);
384 $this->didDiscoverRefs($branch_refs);
386 $refs[] = $branch_refs;
389 return array_mergev($refs);
393 /* -( Internals )---------------------------------------------------------- */
396 private function discoverStreamAncestry(
397 PhabricatorRepositoryGraphStream
$stream,
401 $discover = array($commit);
405 // Find all the reachable, undiscovered commits. Build a graph of the
408 $target = array_pop($discover);
410 if (empty($graph[$target])) {
411 $graph[$target] = array();
414 $parents = $stream->getParents($target);
415 foreach ($parents as $parent) {
416 if ($this->isKnownCommit($parent)) {
420 $graph[$target][$parent] = true;
422 if (empty($seen[$parent])) {
423 $seen[$parent] = true;
424 $discover[] = $parent;
429 // Now, sort them topologically.
430 $commits = $this->reduceGraph($graph);
433 foreach ($commits as $commit) {
434 $epoch = $stream->getCommitDate($commit);
436 // If the epoch doesn't fit into a uint32, treat it as though it stores
437 // the current time. For discussion, see T11537.
438 if ($epoch > 0xFFFFFFFF) {
439 $epoch = PhabricatorTime
::getNow();
442 // If the epoch is not present at all, treat it as though it stores the
443 // value "0". For discussion, see T12062. This behavior is consistent
444 // with the behavior of "git show".
445 if (!strlen($epoch)) {
449 $refs[] = id(new PhabricatorRepositoryCommitRef())
450 ->setIdentifier($commit)
452 ->setIsPermanent($is_permanent)
453 ->setParents($stream->getParents($commit));
460 private function reduceGraph(array $edges) {
461 foreach ($edges as $commit => $parents) {
462 $edges[$commit] = array_keys($parents);
465 $graph = new PhutilDirectedScalarGraph();
466 $graph->addNodes($edges);
468 $commits = $graph->getNodesInTopologicalOrder();
470 // NOTE: We want the most ancestral nodes first, so we need to reverse the
471 // list we get out of AbstractDirectedGraph.
472 $commits = array_reverse($commits);
478 private function isKnownCommit($identifier) {
479 if (isset($this->commitCache
[$identifier])) {
483 if (isset($this->workingSet
[$identifier])) {
487 $this->fillCommitCache(array($identifier));
489 return isset($this->commitCache
[$identifier]);
492 private function fillCommitCache(array $identifiers) {
497 if ($this->repairMode
) {
498 // In repair mode, rediscover the entire repository, ignoring the
499 // database state. The engine still maintains a local cache (the
500 // "Working Set") but we just give up before looking in the database.
504 $max_size = self
::MAX_COMMIT_CACHE_SIZE
;
506 // If we're filling more identifiers than would fit in the cache, ignore
507 // the ones that don't fit. Because the cache is FIFO, overfilling it can
508 // cause the entire cache to miss. See T12296.
509 if (count($identifiers) > $max_size) {
510 $identifiers = array_slice($identifiers, 0, $max_size);
513 // When filling the cache we ignore commits which have been marked as
514 // unreachable, treating them as though they do not exist. When recording
515 // commits later we'll revive commits that exist but are unreachable.
517 $commits = id(new PhabricatorRepositoryCommit())->loadAllWhere(
518 'repositoryID = %d AND commitIdentifier IN (%Ls)
519 AND (importStatus & %d) != %d',
520 $this->getRepository()->getID(),
522 PhabricatorRepositoryCommit
::IMPORTED_UNREACHABLE
,
523 PhabricatorRepositoryCommit
::IMPORTED_UNREACHABLE
);
525 foreach ($commits as $commit) {
526 $this->commitCache
[$commit->getCommitIdentifier()] = true;
529 while (count($this->commitCache
) > $max_size) {
530 array_shift($this->commitCache
);
535 * Sort refs so we process permanent refs first. This makes the whole import
536 * process a little cheaper, since we can publish these commits the first
537 * time through rather than catching them in the refs step.
541 * @param list<DiffusionRepositoryRef> List of refs.
542 * @return list<DiffusionRepositoryRef> Sorted list of refs.
544 private function sortRefs(array $refs) {
545 $repository = $this->getRepository();
546 $publisher = $repository->newPublisher();
548 $head_refs = array();
549 $tail_refs = array();
550 foreach ($refs as $ref) {
551 if ($publisher->isPermanentRef($ref)) {
558 return array_merge($head_refs, $tail_refs);
562 private function recordCommit(
563 PhabricatorRepository
$repository,
570 $commit = new PhabricatorRepositoryCommit();
571 $conn_w = $repository->establishConnection('w');
573 // First, try to revive an existing unreachable commit (if one exists) by
574 // removing the "unreachable" flag. If we succeed, we don't need to do
575 // anything else: we already discovered this commit some time ago.
578 'UPDATE %T SET importStatus = (importStatus & ~%d)
579 WHERE repositoryID = %d AND commitIdentifier = %s',
580 $commit->getTableName(),
581 PhabricatorRepositoryCommit
::IMPORTED_UNREACHABLE
,
582 $repository->getID(),
584 if ($conn_w->getAffectedRows()) {
585 $commit = $commit->loadOneWhere(
586 'repositoryID = %d AND commitIdentifier = %s',
587 $repository->getID(),
590 // After reviving a commit, schedule new daemons for it.
591 $this->didDiscoverCommit($repository, $commit, $epoch, $task_priority);
595 $commit->setRepositoryID($repository->getID());
596 $commit->setCommitIdentifier($commit_identifier);
597 $commit->setEpoch($epoch);
599 $commit->setImportStatus(PhabricatorRepositoryCommit
::IMPORTED_PERMANENT
);
602 $data = new PhabricatorRepositoryCommitData();
605 // If this commit has parents, look up their IDs. The parent commits
606 // should always exist already.
608 $parent_ids = array();
610 $parent_rows = queryfx_all(
612 'SELECT id, commitIdentifier FROM %T
613 WHERE commitIdentifier IN (%Ls) AND repositoryID = %d',
614 $commit->getTableName(),
616 $repository->getID());
618 $parent_map = ipull($parent_rows, 'id', 'commitIdentifier');
620 foreach ($parents as $parent) {
621 if (empty($parent_map[$parent])) {
623 pht('Unable to identify parent "%s"!', $parent));
625 $parent_ids[] = $parent_map[$parent];
628 // Write an explicit 0 so we can distinguish between "really no
629 // parents" and "data not available".
630 if (!$repository->isSVN()) {
631 $parent_ids = array(0);
635 $commit->openTransaction();
638 $data->setCommitID($commit->getID());
641 foreach ($parent_ids as $parent_id) {
644 'INSERT IGNORE INTO %T (childCommitID, parentCommitID)
646 PhabricatorRepository
::TABLE_PARENTS
,
650 $commit->saveTransaction();
652 $this->didDiscoverCommit($repository, $commit, $epoch, $task_priority);
654 if ($this->repairMode
) {
655 // Normally, the query should throw a duplicate key exception. If we
656 // reach this in repair mode, we've actually performed a repair.
657 $this->log(pht('Repaired commit "%s".', $commit_identifier));
660 PhutilEventEngine
::dispatchEvent(
661 new PhabricatorEvent(
662 PhabricatorEventType
::TYPE_DIFFUSION_DIDDISCOVERCOMMIT
,
664 'repository' => $repository,
668 } catch (AphrontDuplicateKeyQueryException
$ex) {
669 $commit->killTransaction();
670 // Ignore. This can happen because we discover the same new commit
671 // more than once when looking at history, or because of races or
672 // data inconsistency or cosmic radiation; in any case, we're still
673 // in a good state if we ignore the failure.
677 private function didDiscoverCommit(
678 PhabricatorRepository
$repository,
679 PhabricatorRepositoryCommit
$commit,
683 $this->queueCommitImportTask(
689 // Update the repository summary table.
691 $commit->establishConnection('w'),
692 'INSERT INTO %T (repositoryID, size, lastCommitID, epoch)
693 VALUES (%d, 1, %d, %d)
694 ON DUPLICATE KEY UPDATE
697 IF(VALUES(epoch) > epoch, VALUES(lastCommitID), lastCommitID),
698 epoch = IF(VALUES(epoch) > epoch, VALUES(epoch), epoch)',
699 PhabricatorRepository
::TABLE_SUMMARY
,
700 $repository->getID(),
705 private function didDiscoverRefs(array $refs) {
706 foreach ($refs as $ref) {
707 $this->workingSet
[$ref->getIdentifier()] = true;
711 private function isInitialImport(array $refs) {
712 $commit_count = count($refs);
714 if ($commit_count <= PhabricatorRepository
::IMPORT_THRESHOLD
) {
715 // If we fetched a small number of commits, assume it's an initial
716 // commit or a stack of a few initial commits.
720 $viewer = $this->getViewer();
721 $repository = $this->getRepository();
723 $any_commits = id(new DiffusionCommitQuery())
725 ->withRepository($repository)
730 // If the repository already has commits, this isn't an import.
738 private function getObservedVersion(PhabricatorRepository
$repository) {
739 if ($repository->isHosted()) {
743 if ($repository->isGit()) {
744 return $this->getGitObservedVersion($repository);
750 private function getGitObservedVersion(PhabricatorRepository
$repository) {
751 $refs = id(new DiffusionLowLevelGitRefQuery())
752 ->setRepository($repository)
758 // In Git, the observed version is the most recently discovered commit
759 // at any repository HEAD. It's possible for this to regress temporarily
760 // if a branch is pushed and then deleted. This is acceptable because it
761 // doesn't do anything meaningfully bad and will fix itself on the next
764 $ref_identifiers = mpull($refs, 'getCommitIdentifier');
765 $ref_identifiers = array_fuse($ref_identifiers);
767 $version = queryfx_one(
768 $repository->establishConnection('w'),
769 'SELECT MAX(id) version FROM %T WHERE repositoryID = %d
770 AND commitIdentifier IN (%Ls)',
771 id(new PhabricatorRepositoryCommit())->getTableName(),
772 $repository->getID(),
779 return (int)$version['version'];
782 private function markUnreachableCommits(PhabricatorRepository
$repository) {
783 if (!$repository->isGit() && !$repository->isHg()) {
787 // Find older versions of refs which we haven't processed yet. We're going
788 // to make sure their commits are still reachable.
789 $old_refs = id(new PhabricatorRepositoryOldRef())->loadAllWhere(
790 'repositoryPHID = %s',
791 $repository->getPHID());
793 // If we don't have any refs to update, bail out before building a graph
794 // stream. In particular, this improves behavior in empty repositories,
795 // where `git log` exits with an error.
800 // We can share a single graph stream across all the checks we need to do.
801 if ($repository->isGit()) {
802 $stream = new PhabricatorGitGraphStream($repository);
803 } else if ($repository->isHg()) {
804 $stream = new PhabricatorMercurialGraphStream($repository);
807 foreach ($old_refs as $old_ref) {
808 $identifier = $old_ref->getCommitIdentifier();
809 $this->markUnreachableFrom($repository, $stream, $identifier);
811 // If nothing threw an exception, we're all done with this ref.
816 private function markUnreachableFrom(
817 PhabricatorRepository
$repository,
818 PhabricatorRepositoryGraphStream
$stream,
821 $unreachable = array();
823 $commit = id(new PhabricatorRepositoryCommit())->loadOneWhere(
824 'repositoryID = %s AND commitIdentifier = %s',
825 $repository->getID(),
831 $look = array($commit);
834 $target = array_pop($look);
836 // If we've already checked this commit (for example, because history
837 // branches and then merges) we don't need to check it again.
838 $target_identifier = $target->getCommitIdentifier();
839 if (isset($seen[$target_identifier])) {
843 $seen[$target_identifier] = true;
845 // See PHI1688. If this commit is already marked as unreachable, we don't
846 // need to consider its ancestors. This may skip a lot of work if many
847 // branches with a lot of shared ancestry are deleted at the same time.
848 if ($target->isUnreachable()) {
853 $stream->getCommitDate($target_identifier);
855 } catch (Exception
$ex) {
860 // This commit is reachable, so we don't need to go any further
865 $unreachable[] = $target;
867 // Find the commit's parents and check them for reachability, too. We
868 // have to look in the database since we no may longer have the commit
869 // in the repository.
871 $commit->establishConnection('w'),
872 'SELECT commit.* FROM %T commit
873 JOIN %T parents ON commit.id = parents.parentCommitID
874 WHERE parents.childCommitID = %d',
875 $commit->getTableName(),
876 PhabricatorRepository
::TABLE_PARENTS
,
882 $parents = id(new PhabricatorRepositoryCommit())
883 ->loadAllFromArray($rows);
884 foreach ($parents as $parent) {
889 $unreachable = array_reverse($unreachable);
891 $flag = PhabricatorRepositoryCommit
::IMPORTED_UNREACHABLE
;
892 foreach ($unreachable as $unreachable_commit) {
893 $unreachable_commit->writeImportStatusFlag($flag);
896 // If anything was unreachable, just rebuild the whole summary table.
897 // We can't really update it incrementally when a commit becomes
900 $this->rebuildSummaryTable($repository);
904 private function rebuildSummaryTable(PhabricatorRepository
$repository) {
905 $conn_w = $repository->establishConnection('w');
909 'SELECT COUNT(*) N, MAX(id) id, MAX(epoch) epoch
910 FROM %T WHERE repositoryID = %d AND (importStatus & %d) != %d',
911 id(new PhabricatorRepositoryCommit())->getTableName(),
912 $repository->getID(),
913 PhabricatorRepositoryCommit
::IMPORTED_UNREACHABLE
,
914 PhabricatorRepositoryCommit
::IMPORTED_UNREACHABLE
);
918 'INSERT INTO %T (repositoryID, size, lastCommitID, epoch)
919 VALUES (%d, %d, %d, %d)
920 ON DUPLICATE KEY UPDATE
922 lastCommitID = VALUES(lastCommitID),
923 epoch = VALUES(epoch)',
924 PhabricatorRepository
::TABLE_SUMMARY
,
925 $repository->getID(),