# HG changeset patch # User Georges Racinet on mutations.racinet.fr <georges@racinet.fr> # Date 1583746536 -3600 # Mon Mar 09 10:35:36 2020 +0100 # Branch heptapod-0-8 # Node ID 687c6dd6073864f72d371fd22e7dad57e715f62d # Parent e79710251b38b6b98feb76def5225363f27541f6 started to rewrap hg-git This is done by subclassing `GitHandler`, for now adding `update_exportable_for_named_branches`, almost identically. The only functional difference is that named branches and topics are always exported, i.e., not depending on the value of `hg-git.export-named-branches` diff --git a/hgext3rd/heptapod/__init__.py b/hgext3rd/heptapod/__init__.py --- a/hgext3rd/heptapod/__init__.py +++ b/hgext3rd/heptapod/__init__.py @@ -22,6 +22,7 @@ from . import ( topic as topicmod, + git, ) eh = exthelper.exthelper() @@ -111,6 +112,11 @@ force=force, **opts) +@command('gitlab-mirror') +def gitlab_mirror(ui, repo): + git.gitlab_mirror(ui, repo) + + def runsystem(orig, ui, cmd, environ, cwd, out): heptapod_env = {k: v for k, v in ui.environ.items() if k.startswith('HEPTAPOD_')} diff --git a/hgext3rd/heptapod/git.py b/hgext3rd/heptapod/git.py new file mode 100644 --- /dev/null +++ b/hgext3rd/heptapod/git.py @@ -0,0 +1,126 @@ +"""Interaction with Git repos. + +It is not expected that the rise of the HGitaly project would remove +all interesting things to do with Git. +""" +from dulwich.protocol import ZERO_SHA +from hggit.git_handler import GitHandler +from mercurial.i18n import _ +from mercurial.node import hex +from mercurial import ( + error, +) +import re + + +class HeptapodGitHandler(GitHandler): + + def get_exportable(self): + """Heptapod version, including named branches and topics. + + This rewraps :meth:`GitHandler.get_exportable` to add named branches + and topics to the returned Git refs + """ + git_refs = super(HeptapodGitHandler, self).get_exportable() + logprefix = 'HeptapodGitHandler.get_exportable ' + repo = self.repo.filtered('served') + ui = repo.ui + + # "exporting" the ZERO_SHA will mean by convention a request to prune + # the corresponding heads -- only a request, we can't really decide + # at this stage. + # The value is a dict mapping refs to reasons (just strings) + to_prune = git_refs[ZERO_SHA] = {} + + txn = repo.currenttransaction() + allow_bookmarks = ui.configbool('experimental', + 'hg-git.bookmarks-on-named-branches') + bm_changes = txn.changes.get('bookmarks') if txn is not None else None + if bm_changes: + ui.note( + "HeptapodGitHandler bookmark changes=%r" % bm_changes) + new_bookmarks = [] + for name, change in bm_changes.items(): + if change[0] is None: + new_bookmarks.append(name) + elif change[1] is None: + # prune Git branch for removed bookmark + to_prune['refs/heads/' + name] = 'deleted-bookmark' + + if new_bookmarks and not allow_bookmarks: + raise error.Abort(_( + "Creating bookmarks is forbidden by default in Heptapod " + "(got these new ones: %r). " + "See https://heptapod.net/pages/faq.html#bookmarks to " + "learn why and how to partially lift " + "that restriction" % new_bookmarks)) + # TODO py3 compat (iteritems) + for new_bm_name, (_ign, new_bm_node) in bm_changes.iteritems(): + new_bm_ctx = repo[new_bm_node] + new_bm_topic = new_bm_ctx.topic() + if new_bm_topic: + raise error.Abort(_( + "Creating or updating bookmarks on topic " + "changesets is forbidden"), + hint="topic %r and bookmark %r for changeset %s" % ( + new_bm_topic, new_bm_name, new_bm_ctx.hex())) + + all_bookmarks = self.repo._bookmarks + # currently, self_filter_for_bookmarks() only does some renaming, + # and doesn't discard any, so this is actually just equivalent to + # hgshas of all bookmarks, but that may change in future hg-git + hgshas_with_bookmark_git_ref = { + hex(all_bookmarks[bm]) + for _, bm in self._filter_for_bookmarks(all_bookmarks)} + for branch, hg_nodes in repo.branchmap().iteritems(): + gb = self.git_branch_for_branchmap_branch(branch) + + if ui.configbool('experimental', + 'hg-git.prune-newly-closed-branches'): + hg_nodes = [n for n in hg_nodes if not repo[n].closesbranch()] + if not hg_nodes: + to_prune['refs/heads/' + gb] = 'closed' + hg_shas = {hex(n) for n in hg_nodes} + + # We ignore bookmarked changesets because: + # - we don't want them in the 'wild' namespace + # - no other Git ref would be relevant for them + hg_shas.difference_update(hgshas_with_bookmark_git_ref) + if not hg_shas: + if hg_nodes and allow_bookmarks: + # after removal of bookmarks, but not before, + # there is no hg head on this named branch: + # schedule potential removal. We don't want to do + # this if bookmarks aren't explicitely allowed because + # this must be a side effect, potentially disturbing, of + # an implicit bookmark move + to_prune['refs/heads/' + gb] = 'only-bookmarks' + continue + + if 1 < len(hg_shas): + ui.note(logprefix, "Multiple heads for branch %r: %r" % ( + branch, hg_shas)) + for hg_sha in hg_shas: + git_refs[hg_sha].heads.add('refs/heads/wild/' + hg_sha) + gca = self.multiple_heads_choose(hg_shas, branch) + if gca is None: + # giving up in order to avoid confusing situations + continue + gca_sha = self.repo[gca].hex() + ui.note(logprefix, + "Chose %r out of multiple heads %r " + "for forwarding branch %r" % ( + gca_sha, hg_shas, branch)) + hg_shas = [gca_sha] + hg_sha = hg_shas.pop() + gb = self.git_branch_for_branchmap_branch(branch) + git_refs[hg_sha].heads.add('refs/heads/' + gb) + return git_refs + + +def gitlab_mirror(ui, repo): + """Export changesets as Git commits in the repo expected by GitLab.""" + handler = HeptapodGitHandler(repo, repo.ui) + hgdir = re.sub(r'\.hg$', '', repo.root) + handler.gitdir = hgdir + '.git' + handler.export_commits() diff --git a/hgext3rd/heptapod/tests/test_hg_git.py b/hgext3rd/heptapod/tests/test_hg_git.py new file mode 100644 --- /dev/null +++ b/hgext3rd/heptapod/tests/test_hg_git.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import + +import subprocess + +from heptapod.testhelpers import ( + LocalRepoWrapper, + ) +from .utils import common_config + + +class GitRepo(object): + + def __init__(self, path): + self.path = path + + @classmethod + def init(cls, path): + subprocess.call(('git', 'init', str(path))) + return cls(path) + + def branch_titles(self): + out = subprocess.check_output(('git', 'branch', '-v'), + cwd=str(self.path)) + split_lines = (l.split(None, 2) for l in out.splitlines()) + return {sp[0]: sp[2] for sp in split_lines} + + +def make_main_repo(path): + """Make repo with 2 public revs; return wrapper, ctx of rev 0 + + The returned ctx is for the first changeset because we'll use it as + a branching point, hence more often than the second. + """ + config = common_config() + config['extensions']['hggit'] = '' + config['phases'] = dict(publish=False) + + wrapper = LocalRepoWrapper.init(path, config=config) + ctx = wrapper.write_commit('foo', content='foo0', message="default0", + return_ctx=True) + wrapper.write_commit('foo', content='foo1', message="default1") + wrapper.set_phase('public', ['.']) + return wrapper, ctx + + +def test_basic(tmpdir): + repo_path = tmpdir.join('repo.hg') + repo, base_ctx = make_main_repo(repo_path) + git_repo = GitRepo.init(tmpdir.join('repo.git')) + repo.command('gitlab-mirror') + + assert git_repo.branch_titles() == {'branch/default': 'default1'} diff --git a/install-requirements.txt b/install-requirements.txt --- a/install-requirements.txt +++ b/install-requirements.txt @@ -1,2 +1,3 @@ Mercurial>=5.2 hg-evolve>=9.2.1 +hg-git>=0.8.13