# HG changeset patch # User Georges Racinet <georges.racinet@cloudcrane.io> # Date 1736679537 -3600 # Sun Jan 12 11:58:57 2025 +0100 # Node ID a1831e878bd49ca41ca0d23bb1f737cbf11fffcd # Parent c410f41dbf1d4b7929eb11e3f0a2a9d8731bbd45 Git testhelpers: comparing repos diff --git a/heptapod/testhelpers/git.py b/heptapod/testhelpers/git.py --- a/heptapod/testhelpers/git.py +++ b/heptapod/testhelpers/git.py @@ -14,6 +14,7 @@ typically solved by installing Git system-wide. """ import os +import re import shutil import subprocess from mercurial import pycompat @@ -35,6 +36,15 @@ return {ref: info['title'] for ref, info in branches.items()} +def git_version(): + out = subprocess.check_output(('git', 'version')).decode().strip() + m = re.match(r'git version (\d+)\.(\d+)\.(\d+)', out) + return tuple(int(m.group(i)) for i in range(1, 4)) + + +GIT_VERSION = git_version() + + class GitRepo(object): """Represent a Git repository, relying on the Git executable. @@ -77,7 +87,9 @@ return out.strip().split(b'|') def get_symref(self, name): - return self.path.join(name).read().strip().split(':', 1)[1].strip() + dotgit = self.path.join('.git') + path = dotgit if dotgit.exists() else self.path + return path.join(name).read().strip().split(':', 1)[1].strip() def set_symref(self, name, target_ref): self.path.join(name).write('ref: %s\n' % target_ref) @@ -110,3 +122,43 @@ lines = self.git('show-ref').splitlines() return {split[1]: split[0] for split in (line.split(b' ', 1) for line in lines)} + + def raw_refs(self, with_root_refs=False): + """Use `git for-each-ref` to query all refs, including "root refs" + + The output is the full `stdout` of the Git process, as bytes. + Each line has 2 or 3 fields: + + - the target object SHA + - the name of the ref (e.g, `refs/heads/main` or `HEAD`) + - its target ref *if* it is a root ref (e.g, `HEAD`). + + The root refs are a subset of symrefs. It includes `HEAD` but not + arbitrary symrefs that can be created with `git symbolic-ref`. + + Git sorts the output on `refname` (e.g., `refs/heads/main` or `HEAD`) + by default, which is fine to compare two repositories. + """ + git_args = ['for-each-ref', + '--format', '%(objectname)s %(refname) %(symref)'] + if with_root_refs: + git_args.append('--include-root-refs') # pragma no cover + + return self.git(*git_args) + + def __eq__(self, other): + """True iff the two repos have the same content. + + THis is done by comparing all refs, including the remotes + """ + # not insisting on the same exact class + if not isinstance(other, GitRepo): + return False + + opts = dict(with_root_refs=True) + if GIT_VERSION < (2, 45, 0): # pragma no cover + opts['with_root_refs'] = False + if self.get_symref('HEAD') != other.get_symref('HEAD'): + return False + + return self.raw_refs(**opts) == other.raw_refs(**opts) diff --git a/heptapod/testhelpers/tests/test_git.py b/heptapod/testhelpers/tests/test_git.py --- a/heptapod/testhelpers/tests/test_git.py +++ b/heptapod/testhelpers/tests/test_git.py @@ -8,7 +8,7 @@ from ..git import GitRepo -def test_branches(tmpdir): +def test_refs(tmpdir): server = tmpdir / 'server.git' # server_repo is bare, hence we cannot commit directly in there server_repo = GitRepo.init(server) @@ -41,3 +41,18 @@ assert server_repo.commit_hash_title('master') == [sha.encode(), b'init commit'] assert server_repo.get_branch_sha(b'master') == sha + + client_repo = GitRepo(client) + assert server_repo != client # wrong class + assert server_repo != client_repo # the client has remote refs + client_repo.git('update-ref', '-d', 'refs/remotes/origin/master') + assert server_repo == client_repo + + # (for Git < 2.45 coverage): same HEAD but an additional branch + (client / 'foo').write('bar') + subprocess.call(('git', 'checkout', '-b', 'other'), cwd=str(client)) + subprocess.call(('git', 'add', 'foo'), cwd=str(client)) + + subprocess.call(('git', 'commit', '-m', 'other branch', '--no-gpg-sign'), + cwd=str(client)) + assert server_repo != client_repo # HG changeset patch # User Georges Racinet <georges.racinet@cloudcrane.io> # Date 1736685358 -3600 # Sun Jan 12 13:35:58 2025 +0100 # Node ID a5fd743bb61503bb8e380a38448fe58c98a21b36 # Parent a1831e878bd49ca41ca0d23bb1f737cbf11fffcd test helpers: separate functions to activate state maintaining Two helpers for in-RAM activation on the Mercurial `localrepo` object and in config dicts of our testhelpers for persistence in `.hg/hgrc`. This makes it easier to use in HGitaly test fixtures, reducing hardcoding and duplication. diff --git a/heptapod/testhelpers/gitlab.py b/heptapod/testhelpers/gitlab.py --- a/heptapod/testhelpers/gitlab.py +++ b/heptapod/testhelpers/gitlab.py @@ -24,6 +24,11 @@ from .git import GitRepo logger = logging.getLogger(__name__) +STATE_MAINTAINER_ACTIVATION_CONFIG = ( + b'hooks', b'pretxnclose.testcase', + b'python:heptapod.hooks.gitlab_mirror.mirror', +) + def patch_gitlab_hooks(monkeypatch, records, action=None): @@ -43,6 +48,26 @@ monkeypatch.setattr(hooks.PostReceive, '__call__', call) +def activate_gitlab_state_maintainer(hg_repo_wrapper): + """Make state maintaining or mirroring to Git repo automatic. + + This is essential to get the state maintaining happen in-transaction. + If the repository is configured with `native=False` (legacy hg-git based + projects), this triggers the mirroring to Git. + """ + hg_repo_wrapper.repo.ui.setconfig(*STATE_MAINTAINER_ACTIVATION_CONFIG) + + +def activate_gitlab_state_maintainer_in_dict(config): # pragma no cover + """Activation in config dicts used before persisting configuration. + + This is used in HGitaly tests, as py-heptapod tests tend to do it + after repo creation, in non-persistent form. + """ + section, key, value = STATE_MAINTAINER_ACTIVATION_CONFIG + config[section] = {key: value} + + @attr.s class GitLabStateMaintainerFixture: """Helper class to create fixtures for GitLab state maintainers. @@ -97,13 +122,7 @@ del self.gitlab_notifs[:] def activate_mirror(self): - """Make mirroring from Mercurial to Git repo automatic. - - This is essential to get the mirroring code to run in-transaction. - """ - self.hg_repo_wrapper.repo.ui.setconfig( - b'hooks', b'pretxnclose.testcase', - b'python:heptapod.hooks.gitlab_mirror.mirror') + activate_gitlab_state_maintainer(self.hg_repo_wrapper) def delete(self): hg_path = self.hg_repo_wrapper.repo.root