Skip to content
Snippets Groups Projects
Commit 12ed203c authored by Georges Racinet's avatar Georges Racinet
Browse files

OperationService.UserFFBranch implementation

Closes #164

In the course of implementation, we introduce the new `changeset_by_commit_id`
helpers which are useful in cases where the input is expected to be a full
Node ID (instead of any Git revspec) and that is to be validated (because
Gitaly does). Would need their separated testing to commit them separately,
too lazy today for that.
parent 5410ca79
No related branches found
No related tags found
2 merge requests!211gRPC engines upgrade and stable branch merge,!206OperationService.UserFFBranch implementation
Pipeline #74632 passed
......@@ -4,6 +4,7 @@
# GNU General Public License version 2 or any later version.
#
# SPDX-License-Identifier: GPL-2.0-or-later
from grpc import StatusCode
import logging
import re
......@@ -201,3 +202,41 @@
collection.add(sha)
return positive, negative
def validate_oid(oid: str):
"""Check that the given commit_id is hexadecimal of correct length
"""
return CHANGESET_HASH_STR_REGEXP.match(oid) is not None
def changeset_by_commit_id(repo, commit_id: str):
"""Return a changeset context from an exact full hexadecimal Node ID.
:raises ValueError: if `commit_id` is not an hexadecimal number of correct
length.
:raises KeyError: if changeset not found.
"""
if not validate_oid(commit_id):
raise ValueError(commit_id)
try:
return repo[commit_id.encode('ascii')]
except error.RepoLookupError:
raise KeyError(commit_id)
def changeset_by_commit_id_abort(repo, commit_id: str, context):
"""Same as :func:`changeset_by_commit_id`, with some error treatment
aborts gRPC context if ``commit_id`` is not an hexadecimal number of
correct length.
:return: the changeset context, or ``None`` if resolution failed
"""
try:
return changeset_by_commit_id(repo, commit_id)
except KeyError:
return None
except ValueError:
context.abort(StatusCode.INVALID_ARGUMENT,
f'cannot parse commit ID: "{commit_id}"')
......@@ -13,8 +13,19 @@
obsutil,
)
from heptapod.gitlab.branch import (
NAMED_BRANCH_PREFIX,
)
from ..branch import (
gitlab_branch_head
)
from ..changelog import (
ancestor,
merge_content,
)
from ..errors import (
structured_abort,
)
from ..logging import LoggerAdapter
from ..revision import (
......@@ -16,6 +27,7 @@
from ..errors import (
structured_abort,
)
from ..logging import LoggerAdapter
from ..revision import (
changeset_by_commit_id_abort,
gitlab_revision_changeset,
......@@ -21,5 +33,6 @@
gitlab_revision_changeset,
validate_oid,
)
from ..servicer import HGitalyServicer
from ..stub.operations_pb2 import (
......@@ -22,7 +35,9 @@
)
from ..servicer import HGitalyServicer
from ..stub.operations_pb2 import (
UserSquashError,
OperationBranchUpdate,
UserFFBranchRequest,
UserFFBranchResponse,
UserSquashRequest,
UserSquashResponse,
......@@ -27,5 +42,6 @@
UserSquashRequest,
UserSquashResponse,
UserSquashError,
)
from ..stub.errors_pb2 import (
ResolveRevisionError,
......@@ -140,3 +156,79 @@
folded = obsutil.latest_unique_successor(end_ctx)
return UserSquashResponse(squash_sha=folded.hex().decode('ascii'))
def UserFFBranch(self,
request: UserFFBranchRequest,
context) -> UserFFBranchResponse:
logger = LoggerAdapter(base_logger, context)
repo = self.load_repo(request.repository, context,
for_mutation_by=request.user)
with_hg_git = not repo.ui.configbool(b'heptapod', b'no-git')
to_publish = changeset_by_commit_id_abort(
repo, request.commit_id, context)
if to_publish is None:
context.abort(
StatusCode.INTERNAL,
f'checking for ancestry: invalid commit: "{request.commit_id}"'
)
old_id = request.expected_old_oid
if old_id and not validate_oid(old_id):
context.abort(StatusCode.INVALID_ARGUMENT,
f'cannot parse commit ID: "{old_id}"')
if not request.branch:
context.abort(StatusCode.INVALID_ARGUMENT, "empty branch name")
if not request.branch.startswith(NAMED_BRANCH_PREFIX):
context.abort(StatusCode.FAILED_PRECONDITION,
"Heptapod fast forwards are currently "
"for named branches only (no topics nor bookmarks)")
current_head = gitlab_branch_head(repo, request.branch)
if to_publish.branch() != current_head.branch():
context.abort(StatusCode.FAILED_PRECONDITION,
"not a fast-forward (Mercurial branch differs)")
fail = False
for cs in merge_content(to_publish, current_head):
if cs.obsolete():
fail = True
fail_msg = "is obsolete"
if cs.isunstable():
fail = True
fail_msg = "is unstable"
if fail:
context.abort(StatusCode.FAILED_PRECONDITION,
f"not a fast-forward (changeset "
f"{cs.hex().decode('ascii')} {fail_msg})")
if old_id and old_id != current_head.hex().decode('ascii'):
# We did not need to resolve before this, but now we do because
# Gitaly has a specific error if resolution fails
if changeset_by_commit_id_abort(repo, old_id, context) is None:
context.abort(StatusCode.INVALID_ARGUMENT,
"cannot resolve expected old object ID: "
"reference not found")
return UserFFBranchResponse()
if ancestor(to_publish, current_head) != current_head.rev():
context.abort(StatusCode.FAILED_PRECONDITION,
"not fast forward")
# TODO use phases.advanceboundary directly? Check cache invalidations
# carefully. ('phases' command is a pain to call and does lots of
# unnecessary stuff).
logger.info("All checks passed, now publishing %r, "
"mirroring to Git=%r", to_publish, with_hg_git)
self.repo_command(repo, context, 'phase',
public=True,
draft=False,
secret=False,
force=False,
rev=[str(to_publish.rev()).encode('ascii')])
return UserFFBranchResponse(branch_update=OperationBranchUpdate(
commit_id=to_publish.hex().decode('ascii'),
))
......@@ -11,6 +11,10 @@
)
import pytest
from mercurial import (
phases,
)
from heptapod.testhelpers.git import GitRepo
from hgext3rd.heptapod.branch import (
gitlab_branches,
......@@ -23,6 +27,9 @@
SKIP_HOOKS_MD_KEY,
)
from hgitaly.stub.operations_pb2 import (
OperationBranchUpdate,
UserFFBranchRequest,
UserFFBranchResponse,
UserSquashRequest,
)
from hgitaly.stub.operations_pb2_grpc import (
......@@ -75,6 +82,12 @@
return self.stub.UserSquash(UserSquashRequest(**kw),
metadata=self.grpc_metadata())
def user_ff_branch(self, **kw):
kw.setdefault('repository', self.grpc_repo)
kw.setdefault('user', self.user)
return self.stub.UserFFBranch(UserFFBranchRequest(**kw),
metadata=self.grpc_metadata())
def list_refs(self, repo=None):
if repo is None:
repo = self.grpc_repo
......@@ -181,3 +194,126 @@
with pytest.raises(RpcError) as exc_info:
squash(**kw)
assert exc_info.value.code() == StatusCode.INVALID_ARGUMENT
@parametrize('project_mode', ('hg-git-project',
'native-project-without-git',
'native-project-with-git',
))
def test_user_ff_branch(operations_fixture, project_mode):
fixture = operations_fixture
ff_branch = fixture.user_ff_branch
fixture.hg_native = project_mode != 'hg-git-project'
if fixture.hg_native:
fixture.with_hg_git_mirroring = (
project_mode == 'native-project-with-git'
)
wrapper = fixture.repo_wrapper
gl_topic = b'topic/default/zetop'
gl_branch = b'branch/default'
# because of the config set by fixture, this leads in all cases to
# creation of a Git repo and its `branch/default` Git branch
ctx0 = wrapper.commit_file('foo')
sha0 = ctx0.hex().decode()
default_head = wrapper.commit_file('foo')
ctx2 = wrapper.commit_file('foo', topic='zetop', message='foo2')
sha2 = ctx2.hex().decode('ascii')
wrapper.commit_file('bar', branch='other', parent=ctx0)
needs_rebase = wrapper.commit_file('old', parent=ctx0,
topic='needs-rebase'
).hex().decode('ascii')
bogus1 = wrapper.commit_file('bogus', topic='bogus',
parent=default_head)
bogus1_sha = bogus1.hex().decode()
# changing topic so that amending bogus1 is not a multiple heads condition
bogus2 = wrapper.commit_file('bogus', topic='bogus2')
bogus2_sha = bogus2.hex().decode()
# let's confirm the mirroring to Git
git_repo = GitRepo(str(wrapper.path) + '.git')
before_ff_git_branches = git_repo.branches()
assert before_ff_git_branches[gl_topic]['title'] == b'foo2'
# making bogus_top1 obsolete
wrapper.update_bin(bogus1.node())
wrapper.amend_file('foo')
before_refs = fixture.list_refs()
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=b'branch/other', commit_id=sha2)
assert exc_info.value.code() == StatusCode.FAILED_PRECONDITION
assert 'branch differ' in exc_info.value.details()
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=gl_branch, commit_id=bogus2_sha)
assert exc_info.value.code() == StatusCode.FAILED_PRECONDITION
assert 'unstable' in exc_info.value.details()
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=gl_branch, commit_id=bogus1_sha)
assert exc_info.value.code() == StatusCode.FAILED_PRECONDITION
assert 'obsolete' in exc_info.value.details()
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=gl_topic, commit_id=sha2)
assert exc_info.value.code() == StatusCode.FAILED_PRECONDITION
assert 'named branches only' in exc_info.value.details()
# case where nothing is pathological, but is not a fast-forward
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=gl_branch, commit_id=needs_rebase)
assert exc_info.value.code() == StatusCode.FAILED_PRECONDITION
assert 'not fast forward' in exc_info.value.details()
# basic errors, missing and unresolvable arguments
with pytest.raises(RpcError) as exc_info:
ff_branch(commit_id=sha2)
assert exc_info.value.code() == StatusCode.INVALID_ARGUMENT
assert 'empty branch' in exc_info.value.details()
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=gl_branch, commit_id='not-a-hash')
assert exc_info.value.code() == StatusCode.INVALID_ARGUMENT
assert 'parse commit ID' in exc_info.value.details()
unknown_oid = '01beef23' * 5
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=gl_branch, commit_id=unknown_oid)
assert exc_info.value.code() == StatusCode.INTERNAL
assert 'invalid commit' in exc_info.value.details()
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=gl_branch, commit_id=sha2,
expected_old_oid=unknown_oid)
assert exc_info.value.code() == StatusCode.INVALID_ARGUMENT
assert 'old object' in exc_info.value.details()
with pytest.raises(RpcError) as exc_info:
ff_branch(branch=gl_branch, commit_id=sha2,
expected_old_oid='12deadbeef') # short hash
assert exc_info.value.code() == StatusCode.INVALID_ARGUMENT
assert 'parse commit ID' in exc_info.value.details()
# old oid mismatch
assert ff_branch(branch=gl_branch, commit_id=sha2,
expected_old_oid=sha0) == UserFFBranchResponse()
assert fixture.list_refs() == before_refs
# Actual call expected to succeed
resp = ff_branch(branch=gl_branch, commit_id=sha2)
# checking change on default branch ref, and only this ref.
expected_refs = before_refs.copy()
expected_refs[b'refs/heads/' + gl_branch] = sha2
del expected_refs[b'refs/heads/' + gl_topic]
assert fixture.list_refs() == expected_refs
assert resp.branch_update == OperationBranchUpdate(commit_id=sha2,
repo_created=False,
branch_created=False)
wrapper.reload()
assert wrapper.repo[ctx2.rev()].phase() == phases.public
......@@ -15,6 +15,8 @@
from hgitaly.stub.shared_pb2 import User
from hgitaly.stub.operations_pb2 import (
OperationBranchUpdate,
UserFFBranchRequest,
UserSquashRequest,
)
from hgitaly.stub.operations_pb2_grpc import (
......@@ -81,3 +83,74 @@
assert_compare_errors(commit_message=None,
start_sha=gl_branch, end_sha=gl_topic)
assert_compare_errors(author=None, start_sha=gl_branch, end_sha=gl_topic)
def test_compare_ff_branch(comparison):
"""This test is mostly about error cases, as comparing the """
fixture = comparison
git_repo = fixture.git_repo
wrapper = fixture.hg_repo_wrapper
gl_branch = b'branch/default'
gl_topic = b'topic/default/sampletop'
ctx0 = wrapper.write_commit('foo')
sha0 = ctx0.hex()
sha1 = wrapper.write_commit('foo').hex()
ctx2 = wrapper.write_commit('zoo', topic='sampletop')
sha2 = ctx2.hex()
git_sha2 = git_repo.branches()[gl_topic]['sha']
ctx_old = wrapper.write_commit('old', parent=ctx0, topic='needs-rebase')
rpc_helper = fixture.rpc_helper(
stub_cls=OperationServiceStub,
method_name='UserFFBranch',
error_details_normalizer=lambda s, **kw: s.lower(),
request_cls=UserFFBranchRequest,
request_defaults=dict(user=fixture.user),
request_sha_attrs=['commit_id',
'expected_old_oid'],
)
assert_compare = rpc_helper.assert_compare
assert_compare_errors = rpc_helper.assert_compare_errors
# A working Gitaly call, as a baseline that cannot be directly compared.
git_resp = rpc_helper.call_git_only(branch=gl_branch,
commit_id=sha2,
expected_old_oid=sha1,
)
assert git_resp.branch_update == OperationBranchUpdate(
commit_id=git_sha2,
repo_created=False,
branch_created=False,
)
assert git_repo.commit_hash_title('branch/default')[0] == git_sha2
# revision resolution errors
unknown_sha = '2134cafe' * 5
assert_compare_errors(commit_id=unknown_sha,
expected_old_oid=sha0.decode(),
branch=gl_branch)
assert_compare_errors(commit_id='not-an-id',
same_details=False,
expected_old_oid=sha0.decode(),
branch=gl_branch)
assert_compare_errors(commit_id=sha2,
same_details=False,
expected_old_oid='not-an-id',
branch=gl_branch)
assert_compare_errors(commit_id=sha2,
expected_old_oid=unknown_sha,
branch=gl_branch)
# expected_old_oid mismatch
assert_compare(commit_id=sha2,
expected_old_oid=sha0,
branch=gl_branch)
# errors on missing arguments
assert_compare_errors(commit_id=sha2)
assert_compare_errors(user=None, branch=gl_branch, commit_id=sha2)
# Not a fast-forward
assert_compare_errors(commit_id=ctx_old.hex().decode(),
branch=gl_branch)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment