diff --git a/readthedocs/api/v2/views/integrations.py b/readthedocs/api/v2/views/integrations.py index 011bec245d2..ba5049b4871 100644 --- a/readthedocs/api/v2/views/integrations.py +++ b/readthedocs/api/v2/views/integrations.py @@ -17,7 +17,7 @@ from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.views import APIView -from readthedocs.builds.constants import LATEST +from readthedocs.builds.constants import BRANCH, LATEST from readthedocs.core.signals import webhook_bitbucket, webhook_github, webhook_gitlab from readthedocs.core.views.hooks import ( build_branches, @@ -294,7 +294,7 @@ def get_closed_external_version_response(self, project): def update_default_branch(self, default_branch): """ - Update the `Version.identifer` for `latest` with the VCS's `default_branch`. + Update the `Version.identifier` for `latest` with the VCS's `default_branch`. The VCS's `default_branch` is the branch cloned when there is no specific branch specified (e.g. `git clone `). @@ -316,7 +316,8 @@ def update_default_branch(self, default_branch): # Always check for the machine attribute, since latest can be user created. # RTD doesn't manage those. self.project.versions.filter(slug=LATEST, machine=True).update( - identifier=default_branch + identifier=default_branch, + type=BRANCH, ) diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index e782f52599f..3f76b1dbd91 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -30,7 +30,6 @@ EXTERNAL_VERSION_STATES, INTERNAL, LATEST, - NON_REPOSITORY_VERSIONS, PREDEFINED_MATCH_ARGS, PREDEFINED_MATCH_ARGS_VALUES, STABLE, @@ -292,9 +291,9 @@ def ref(self): def vcs_url(self): version_name = self.verbose_name if not self.is_external: - if self.slug == STABLE: + if self.slug == STABLE and self.machine: version_name = self.ref - elif self.slug == LATEST: + elif self.slug == LATEST and self.machine: version_name = self.project.get_default_branch() else: version_name = self.slug @@ -341,10 +340,10 @@ def commit_name(self): """ # LATEST is special as it is usually a branch but does not contain the # name in verbose_name. - if self.slug == LATEST: + if self.slug == LATEST and self.machine: return self.project.get_default_branch() - if self.slug == STABLE: + if self.slug == STABLE and self.machine: if self.type == BRANCH: # Special case, as we do not store the original branch name # that the stable version works on. We can only interpolate the @@ -355,11 +354,6 @@ def commit_name(self): return self.identifier[len('origin/'):] return self.identifier - # By now we must have handled all special versions. - if self.slug in NON_REPOSITORY_VERSIONS: - # pylint: disable=broad-exception-raised - raise Exception('All special versions must be handled by now.') - if self.type in (BRANCH, TAG): # If this version is a branch or a tag, the verbose_name will # contain the actual name. We cannot use identifier as this might diff --git a/readthedocs/doc_builder/director.py b/readthedocs/doc_builder/director.py index fcbb4c47c2e..a289b3bc4eb 100644 --- a/readthedocs/doc_builder/director.py +++ b/readthedocs/doc_builder/director.py @@ -17,7 +17,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from readthedocs.builds.constants import EXTERNAL +from readthedocs.builds.constants import EXTERNAL, LATEST from readthedocs.core.utils.filesystem import safe_open from readthedocs.doc_builder.config import load_yaml_config from readthedocs.doc_builder.exceptions import BuildUserError @@ -226,8 +226,15 @@ def checkout(self): self.vcs_repository.update() identifier = self.data.build_commit or self.data.version.identifier - log.info("Checking out.", identifier=identifier) - self.vcs_repository.checkout(identifier) + is_rtd_latest = self.data.version.slug == LATEST and self.data.version.machine + skip_checkout = not identifier or ( + is_rtd_latest and not self.data.project.default_branch + ) + if skip_checkout: + log.info("Skipping checkout, using default branch.") + else: + log.info("Checking out.", identifier=identifier) + self.vcs_repository.checkout(identifier) # The director is responsible for understanding which config file to use for a build. # In order to reproduce a build 1:1, we may use readthedocs_yaml_path defined by the build diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index d27e0fafb2d..85a9e871279 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -653,19 +653,10 @@ def save(self, *args, **kwargs): try: if not self.versions.filter(slug=LATEST).exists(): - self.versions.create_latest() + self.versions.create_latest(identifier=self.get_default_branch()) except Exception: log.exception("Error creating default branches") - # Update `Version.identifier` for `latest` with the default branch the user has selected, - # even if it's `None` (meaning to match the `default_branch` of the repository) - # NOTE: this code is required to be *after* ``create_latest()``. - # It has to be updated after creating LATEST originally. - log.debug( - "Updating default branch.", slug=LATEST, identifier=self.default_branch - ) - self.versions.filter(slug=LATEST).update(identifier=self.default_branch) - def delete(self, *args, **kwargs): from readthedocs.projects.tasks.utils import clean_project_resources @@ -1211,7 +1202,8 @@ def update_latest_version(self): """ latest = self.get_latest_version() if not latest: - latest = self.versions.create_latest() + latest = self.versions.create_latest(identifier=self.get_default_branch()) + if not latest.machine: return @@ -1300,7 +1292,13 @@ def get_default_version(self): return LATEST def get_default_branch(self): - """Get the version representing 'latest'.""" + """ + Get the branch/tag name of the version representing 'latest'. + + If the project has a default branch explicitly set, we use that, + otherwise we try to get it from the remote repository, + or fallback to the default branch of the VCS backend. + """ if self.default_branch: return self.default_branch diff --git a/readthedocs/projects/tasks/builds.py b/readthedocs/projects/tasks/builds.py index 19a0e508a85..326345913da 100644 --- a/readthedocs/projects/tasks/builds.py +++ b/readthedocs/projects/tasks/builds.py @@ -22,6 +22,7 @@ from readthedocs.builds.constants import ( ARTIFACT_TYPES, ARTIFACT_TYPES_WITHOUT_MULTIPLE_FILES_SUPPORT, + BRANCH, BUILD_FINAL_STATES, BUILD_STATE_BUILDING, BUILD_STATE_CLONING, @@ -32,6 +33,7 @@ BUILD_STATUS_FAILURE, BUILD_STATUS_SUCCESS, EXTERNAL, + LATEST, UNDELETABLE_ARTIFACT_TYPES, ) from readthedocs.builds.models import APIVersion, Build @@ -119,6 +121,10 @@ class TaskData: config: BuildConfigV2 = None project: APIProject = None version: APIVersion = None + # Default branch for the project. + # This is used to update the latest version in case the project doesn't + # have an explicit default branch set. + default_branch: str = None # Dictionary returned from the API. build: dict = field(default_factory=dict) @@ -634,17 +640,28 @@ def on_success(self, retval, task_id, args, kwargs): # TODO: remove this condition and *always* update the DB Version instance if "html" in valid_artifacts: try: - self.data.api_client.version(self.data.version.pk).patch( - { - "built": True, - "documentation_type": self.data.version.documentation_type, - "has_pdf": "pdf" in valid_artifacts, - "has_epub": "epub" in valid_artifacts, - "has_htmlzip": "htmlzip" in valid_artifacts, - "build_data": self.data.version.build_data, - "addons": self.data.version.addons, - } + payload = { + "built": True, + "documentation_type": self.data.version.documentation_type, + "has_pdf": "pdf" in valid_artifacts, + "has_epub": "epub" in valid_artifacts, + "has_htmlzip": "htmlzip" in valid_artifacts, + "build_data": self.data.version.build_data, + "addons": self.data.version.addons, + } + # Update the latest version to point to the current default branch + # if the project doesn't have a default branch set. + is_rtd_latest = ( + self.data.version.slug == LATEST and self.data.version.machine ) + if ( + is_rtd_latest + and not self.data.project.default_branch + and self.data.default_branch + ): + payload["identifier"] = self.data.default_branch + payload["type"] = BRANCH + self.data.api_client.version(self.data.version.pk).patch(payload) except HttpClientError: # NOTE: I think we should fail the build if we cannot update # the version at this point. Otherwise, we will have inconsistent data @@ -777,6 +794,18 @@ def execute(self): with self.data.build_director.vcs_environment: self.data.build_director.setup_vcs() + # Get the default branch of the repository if the project doesn't + # have an explicit default branch set and we are building latest. + # The identifier from latest will be updated with this value + # if the build succeeds. + is_rtd_latest = ( + self.data.version.slug == LATEST and self.data.version.machine + ) + if is_rtd_latest and not self.data.project.default_branch: + self.data.default_branch = ( + self.data.build_director.vcs_repository.get_default_branch() + ) + # Sync tags/branches from VCS repository into Read the Docs' # `Version` objects in the database. This method runs commands # (e.g. "hg tags") inside the VCS environment, so it requires to be diff --git a/readthedocs/projects/tests/test_build_tasks.py b/readthedocs/projects/tests/test_build_tasks.py index de60cd4d5f3..dd1da05b238 100644 --- a/readthedocs/projects/tests/test_build_tasks.py +++ b/readthedocs/projects/tests/test_build_tasks.py @@ -312,6 +312,8 @@ def test_build_updates_documentation_type(self, load_yaml_config): "has_pdf": True, "has_epub": True, "has_htmlzip": False, + "identifier": mock.ANY, + "type": "branch", } @pytest.mark.parametrize( @@ -585,6 +587,8 @@ def test_successful_build( "has_pdf": True, "has_epub": True, "has_htmlzip": True, + "identifier": mock.ANY, + "type": "branch", } # Set project has valid clone assert self.requests_mock.request_history[8]._request.method == "PATCH" @@ -691,7 +695,7 @@ def test_failed_build( assert revoke_key_request.path == "/api/v2/revoke/" @mock.patch("readthedocs.doc_builder.director.load_yaml_config") - def test_build_commands_executed( + def test_build_commands_executed_latest_version( self, load_yaml_config, ): @@ -719,7 +723,7 @@ def test_build_commands_executed( self.mocker.mocks["git.Backend.run"].assert_has_calls( [ - mock.call("git", "clone", "--depth", "1", mock.ANY, "."), + mock.call("git", "clone", "--depth", "1", "--", mock.ANY, "."), mock.call( "git", "fetch", @@ -730,6 +734,292 @@ def test_build_commands_executed( "--depth", "50", ), + mock.call( + "git", + "symbolic-ref", + "--short", + "refs/remotes/origin/HEAD", + demux=True, + record=False, + ), + mock.call( + "git", + "ls-remote", + "--tags", + "--heads", + "--", + mock.ANY, + demux=True, + record=False, + ), + ] + ) + + python_version = settings.RTD_DOCKER_BUILD_SETTINGS["tools"]["python"]["3"] + self.mocker.mocks["environment.run"].assert_has_calls( + [ + mock.call( + "cat", + "readthedocs.yml", + cwd="/tmp/readthedocs-tests/git-repository", + ), + mock.call("asdf", "install", "python", python_version), + mock.call("asdf", "global", "python", python_version), + mock.call("asdf", "reshim", "python", record=False), + mock.call( + "python", + "-mpip", + "install", + "-U", + "virtualenv", + "setuptools", + ), + mock.call( + "python", + "-mvirtualenv", + "$READTHEDOCS_VIRTUALENV_PATH", + bin_path=None, + cwd=None, + ), + mock.call( + mock.ANY, + "-m", + "pip", + "install", + "--upgrade", + "--no-cache-dir", + "pip", + "setuptools", + bin_path=mock.ANY, + cwd=mock.ANY, + ), + mock.call( + mock.ANY, + "-m", + "pip", + "install", + "--upgrade", + "--no-cache-dir", + "sphinx", + "readthedocs-sphinx-ext", + bin_path=mock.ANY, + cwd=mock.ANY, + ), + # FIXME: shouldn't this one be present here? It's not now because + # we are mocking `append_conf` which is the one that triggers this + # command. + # + # mock.call( + # 'cat', + # 'docs/conf.py', + # cwd=mock.ANY, + # ), + mock.call( + mock.ANY, + "-m", + "sphinx", + "-T", + "-E", + "-b", + "html", + "-d", + "_build/doctrees", + "-D", + "language=en", + ".", + "$READTHEDOCS_OUTPUT/html", + cwd=mock.ANY, + bin_path=mock.ANY, + ), + mock.call( + mock.ANY, + "-m", + "sphinx", + "-T", + "-E", + "-b", + "readthedocssinglehtmllocalmedia", + "-d", + "_build/doctrees", + "-D", + "language=en", + ".", + "$READTHEDOCS_OUTPUT/htmlzip", + cwd=mock.ANY, + bin_path=mock.ANY, + ), + mock.call( + "mktemp", + "--directory", + record=False, + ), + mock.call( + "mv", + mock.ANY, + mock.ANY, + cwd=mock.ANY, + record=False, + ), + mock.call( + "mkdir", + "--parents", + mock.ANY, + cwd=mock.ANY, + record=False, + ), + mock.call( + "zip", + "--recurse-paths", + "--symlinks", + mock.ANY, + mock.ANY, + cwd=mock.ANY, + record=False, + ), + mock.call( + mock.ANY, + "-m", + "sphinx", + "-T", + "-E", + "-b", + "latex", + "-d", + "_build/doctrees", + "-D", + "language=en", + ".", + "$READTHEDOCS_OUTPUT/pdf", + cwd=mock.ANY, + bin_path=mock.ANY, + ), + mock.call("cat", "latexmkrc", cwd=mock.ANY), + # NOTE: pdf `mv` commands and others are not here because the + # PDF resulting file is not found in the process (`_post_build`) + mock.call( + mock.ANY, + "-m", + "sphinx", + "-T", + "-E", + "-b", + "epub", + "-d", + "_build/doctrees", + "-D", + "language=en", + ".", + "$READTHEDOCS_OUTPUT/epub", + cwd=mock.ANY, + bin_path=mock.ANY, + ), + mock.call( + "mv", + mock.ANY, + "/tmp/project-latest.epub", + cwd=mock.ANY, + record=False, + ), + mock.call( + "rm", + "--recursive", + "$READTHEDOCS_OUTPUT/epub", + cwd=mock.ANY, + record=False, + ), + mock.call( + "mkdir", + "--parents", + "$READTHEDOCS_OUTPUT/epub", + cwd=mock.ANY, + record=False, + ), + mock.call( + "mv", + "/tmp/project-latest.epub", + mock.ANY, + cwd=mock.ANY, + record=False, + ), + mock.call( + "test", + "-x", + "_build/html", + record=False, + cwd=mock.ANY, + ), + # FIXME: I think we are hitting this issue here: + # https://github.com/pytest-dev/pytest-mock/issues/234 + mock.call("lsb_release", "--description", record=False, demux=True), + mock.call("python", "--version", record=False, demux=True), + mock.call( + "dpkg-query", + "--showformat", + "${package} ${version}\\n", + "--show", + record=False, + demux=True, + ), + mock.call( + "python", + "-m", + "pip", + "list", + "--pre", + "--local", + "--format", + "json", + record=False, + demux=True, + ), + ] + ) + + @mock.patch("readthedocs.doc_builder.director.load_yaml_config") + def test_build_commands_executed_non_machine_version( + self, + load_yaml_config, + ): + load_yaml_config.return_value = get_build_config( + { + "version": 2, + "formats": "all", + "sphinx": { + "configuration": "docs/conf.py", + }, + }, + validate=True, + ) + + self.version.machine = False + self.version.save() + + # Create the artifact paths, so it's detected by the builder + os.makedirs(self.project.artifact_path(version=self.version.slug, type_="html")) + os.makedirs(self.project.artifact_path(version=self.version.slug, type_="json")) + os.makedirs( + self.project.artifact_path(version=self.version.slug, type_="htmlzip") + ) + os.makedirs(self.project.artifact_path(version=self.version.slug, type_="epub")) + os.makedirs(self.project.artifact_path(version=self.version.slug, type_="pdf")) + + self._trigger_update_docs_task() + + self.mocker.mocks["git.Backend.run"].assert_has_calls( + [ + mock.call("git", "clone", "--depth", "1", "--", mock.ANY, "."), + mock.call( + "git", + "fetch", + "origin", + "--force", + "--prune", + "--prune-tags", + "--depth", + "50", + "--", + "refs/heads/master:refs/remotes/origin/master", + ), mock.call( "git", "show-ref", @@ -746,6 +1036,7 @@ def test_build_commands_executed( "ls-remote", "--tags", "--heads", + "--", mock.ANY, demux=True, record=False, diff --git a/readthedocs/rtd_tests/tests/test_sync_versions.py b/readthedocs/rtd_tests/tests/test_sync_versions.py index a7934726fac..3739ae7b80e 100644 --- a/readthedocs/rtd_tests/tests/test_sync_versions.py +++ b/readthedocs/rtd_tests/tests/test_sync_versions.py @@ -302,6 +302,7 @@ def test_update_latest_version_type(self): ) latest_version = self.pip.versions.get(slug=LATEST) + self.assertIsNone(self.pip.default_branch) self.assertEqual(latest_version.type, BRANCH) self.assertEqual(latest_version.identifier, "master") self.assertEqual(latest_version.verbose_name, "latest") @@ -720,11 +721,34 @@ def test_machine_attr_when_user_define_latest_tag_and_delete_it(self): # The latest isn't stuck with the previous commit version_latest = self.pip.versions.get(slug="latest") + self.assertIsNone(self.pip.default_branch) + self.assertTrue(version_latest.machine) self.assertEqual( "master", version_latest.identifier, ) + + # Test with an explicit default branch (tag). + self.pip.default_branch = "default-tag" + self.pip.save() + + tags_data = [ + { + "identifier": "1abc2def3", + "verbose_name": "default-tag", + } + ] + + sync_versions_task( + self.pip.pk, + branches_data=branches_data, + tags_data=tags_data, + ) + + version_latest = self.pip.versions.get(slug="latest") self.assertTrue(version_latest.machine) + self.assertEqual(version_latest.identifier, "default-tag") + self.assertEqual(version_latest.type, TAG) def test_machine_attr_when_user_define_latest_branch_and_delete_it(self): """The user creates a branch named ``latest`` on an existing repo, when @@ -757,6 +781,7 @@ def test_machine_attr_when_user_define_latest_branch_and_delete_it(self): "origin/latest", version_latest.identifier, ) + self.assertFalse(version_latest.machine) # Deleting the branch should return the RTD's latest branches_data = [ @@ -774,11 +799,36 @@ def test_machine_attr_when_user_define_latest_branch_and_delete_it(self): # The latest isn't stuck with the previous branch version_latest = self.pip.versions.get(slug="latest") + self.assertIsNone(self.pip.default_branch) + self.assertTrue(version_latest.machine) self.assertEqual( "master", version_latest.identifier, ) + + # Test with an explicit default branch. + branches_data = [ + { + "identifier": "origin/master", + "verbose_name": "master", + }, + { + "identifier": "origin/default-branch", + "verbose_name": "default-branch", + }, + ] + self.pip.default_branch = "default-branch" + self.pip.save() + sync_versions_task( + self.pip.pk, + branches_data=branches_data, + tags_data=[], + ) + + version_latest = self.pip.versions.get(slug="latest") self.assertTrue(version_latest.machine) + self.assertEqual(version_latest.identifier, "default-branch") + self.assertEqual(version_latest.type, BRANCH) def test_deletes_version_with_same_identifier(self): branches_data = [ diff --git a/readthedocs/rtd_tests/tests/test_version.py b/readthedocs/rtd_tests/tests/test_version.py index 8b3f1dab096..82801bc6241 100644 --- a/readthedocs/rtd_tests/tests/test_version.py +++ b/readthedocs/rtd_tests/tests/test_version.py @@ -2,7 +2,15 @@ from django.test.utils import override_settings from django_dynamic_fixture import get -from readthedocs.builds.constants import BRANCH, EXTERNAL, LATEST, TAG +from readthedocs.builds.constants import ( + BRANCH, + EXTERNAL, + LATEST, + LATEST_VERBOSE_NAME, + STABLE, + STABLE_VERBOSE_NAME, + TAG, +) from readthedocs.builds.models import Version from readthedocs.projects.models import Project @@ -26,20 +34,22 @@ def setUp(self): self.branch_version = get( Version, identifier="origin/stable", - verbose_name="stable", - slug="stable", + verbose_name=STABLE_VERBOSE_NAME, + slug=STABLE, project=self.pip, active=True, type=BRANCH, + machine=True, ) self.tag_version = get( Version, - identifier="origin/master", - verbose_name="latest", - slug="latest", + identifier="master", + verbose_name=LATEST_VERBOSE_NAME, + slug=LATEST, project=self.pip, active=True, type=TAG, + machine=True, ) self.subproject = get(Project, slug="subproject", language="en") diff --git a/readthedocs/rtd_tests/tests/test_version_commit_name.py b/readthedocs/rtd_tests/tests/test_version_commit_name.py index 167c92b9bd1..161afc296a8 100644 --- a/readthedocs/rtd_tests/tests/test_version_commit_name.py +++ b/readthedocs/rtd_tests/tests/test_version_commit_name.py @@ -21,6 +21,7 @@ def test_branch_name_made_friendly_when_sha(self): slug=STABLE, verbose_name=STABLE, type=TAG, + machine=True, ) # we shorten commit hashes to keep things readable self.assertEqual(version.identifier_friendly, "3d92b728") @@ -52,6 +53,7 @@ def test_branch_with_name_stable(self): slug=STABLE, verbose_name="stable", type=BRANCH, + machine=True, ) self.assertEqual(version.commit_name, "stable") @@ -62,6 +64,7 @@ def test_stable_version_tag(self): slug=STABLE, verbose_name=STABLE, type=TAG, + machine=True, ) self.assertEqual( version.commit_name, @@ -77,6 +80,7 @@ def test_hg_latest_branch(self): verbose_name=LATEST, type=BRANCH, project=hg_project, + machine=True, ) self.assertEqual(version.commit_name, "default") @@ -85,10 +89,11 @@ def test_git_latest_branch(self): version = new( Version, project=git_project, - identifier="origin/master", + identifier="master", slug=LATEST, verbose_name=LATEST, type=BRANCH, + machine=True, ) self.assertEqual(version.commit_name, "master") diff --git a/readthedocs/vcs_support/backends/bzr.py b/readthedocs/vcs_support/backends/bzr.py index 7e60e5d3fe3..517655834a3 100644 --- a/readthedocs/vcs_support/backends/bzr.py +++ b/readthedocs/vcs_support/backends/bzr.py @@ -13,7 +13,6 @@ class Backend(BaseVCS): """Bazaar VCS backend.""" supports_tags = True - fallback_branch = "" def clone(self): self.make_clean_working_dir() diff --git a/readthedocs/vcs_support/backends/git.py b/readthedocs/vcs_support/backends/git.py index 2cd16baa50b..253224b77a9 100644 --- a/readthedocs/vcs_support/backends/git.py +++ b/readthedocs/vcs_support/backends/git.py @@ -116,8 +116,8 @@ def get_remote_fetch_refspec(self): # Git backend. # If version_identifier is empty, then the fetch operation cannot know what to fetch # and will fetch everything, in order to build what might be defined elsewhere - # as the "default branch". This can be the case for an initial build started BEFORE - # a webhook or sync versions task has concluded what the default branch is. + # as the "default branch". This can be the case for old projects that don't have + # their latest branch in sync with the default branch. if self.version_type == BRANCH and self.version_identifier: # Here we point directly to the remote branch name and update our local remote # refspec to point here. @@ -173,7 +173,7 @@ def clone(self): # of the default branch. Once this case has been made redundant, we can have # --no-checkout for all clones. # --depth 1: Shallow clone, fetch as little data as possible. - cmd = ["git", "clone", "--depth", "1", self.repo_url, "."] + cmd = ["git", "clone", "--depth", "1", "--", self.repo_url, "."] try: # TODO: Explain or remove the return value @@ -199,12 +199,16 @@ def fetch(self): "--depth", str(self.repo_depth), ] - remote_reference = self.get_remote_fetch_refspec() - - if remote_reference: - # TODO: We are still fetching the latest 50 commits. - # A PR might have another commit added after the build has started... - cmd.append(remote_reference) + is_rtd_latest = ( + self.verbose_name == LATEST_VERBOSE_NAME and self.version_machine + ) + omit_remote_reference = is_rtd_latest and not self.project.default_branch + if not omit_remote_reference: + remote_reference = self.get_remote_fetch_refspec() + if remote_reference: + # TODO: We are still fetching the latest 50 commits. + # A PR might have another commit added after the build has started... + cmd.extend(["--", remote_reference]) # Log a warning, except for machine versions since it's a known bug that # we haven't stored a remote refspec in Version for those "stable" versions. @@ -295,6 +299,25 @@ def checkout_revision(self, revision): RepositoryError.FAILED_TO_CHECKOUT.format(revision), ) from exc + def get_default_branch(self): + """ + Return the default branch of the repository. + + The default branch is the branch that is checked out when cloning the + repository. This is usually master or main, it can be configured + in the repository settings. + + The ``git symbolic-ref`` command will produce an output like: + + .. code-block:: text + + origin/main + """ + cmd = ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"] + _, stdout, _ = self.run(*cmd, demux=True, record=False) + default_branch = stdout.strip().removeprefix("origin/") + return default_branch or self.fallback_branch + def lsremote(self, include_tags=True, include_branches=True): """ Use ``git ls-remote`` to list branches and tags without cloning the repository. @@ -310,7 +333,7 @@ def lsremote(self, include_tags=True, include_branches=True): if include_branches: extra_args.append("--heads") - cmd = ["git", "ls-remote", *extra_args, self.repo_url] + cmd = ["git", "ls-remote", *extra_args, "--", self.repo_url] self.check_working_dir() _, stdout, _ = self.run(*cmd, demux=True, record=False) diff --git a/readthedocs/vcs_support/base.py b/readthedocs/vcs_support/base.py index 3304fefef24..e89f26db41e 100644 --- a/readthedocs/vcs_support/base.py +++ b/readthedocs/vcs_support/base.py @@ -48,6 +48,8 @@ class BaseVCS: # Whether this VCS supports listing remotes (branches, tags) without cloning supports_lsremote = False + fallback_branch = "" + # ========================================================================= # General methods # ========================================================================= @@ -63,7 +65,6 @@ def __init__( version_type=None, **kwargs ): - self.default_branch = project.default_branch self.project = project self.name = project.name self.repo_url = project.clean_repo @@ -163,3 +164,6 @@ def update_submodules(self, config): :type config: readthedocs.config.BuildConfigBase """ raise NotImplementedError + + def get_default_branch(self): + return self.fallback_branch