Mercurial > hg-git-serve
diff src/hgext3rd/hggit_serve/_export.py @ 12:f630d9904ea7
Reorganize project into multiple files.
| author | Paul Fisher <paul@pfish.zone> |
|---|---|
| date | Wed, 18 Feb 2026 16:17:05 -0500 |
| parents | src/hgext3rd/hggit_serve.py@ce204bcc4e04 |
| children | 00bdfac5416c |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/hgext3rd/hggit_serve/_export.py Wed Feb 18 16:17:05 2026 -0500 @@ -0,0 +1,188 @@ +from __future__ import annotations + +import binascii +import typing as t + +import dulwich.refs +import mercurial.error as hgerr +from hggit import git_handler + +if t.TYPE_CHECKING: + import mercurial.interfaces.repository as hgrepo + import mercurial.ui as hgui + + class GittyRepo(hgrepo.IRepo, t.Protocol): + githandler: git_handler.GitHandler + + +def is_gitty(repo: hgrepo.IRepo) -> t.TypeGuard[GittyRepo]: + """Ensures that we have hg-git installed and active.""" + return hasattr(repo, 'githandler') + + +PULL = b'pull' +PUSH = b'push' + +SERVICE_PERMISSIONS = { + b'git-upload-pack': PULL, + b'git-receive-pack': PUSH, +} +"""The Mercurial permission corresponding to each Git action. + +These seem backwards because the direction of up/download is relative to +the server, so when the client pulls, the server is *uploading*, +and when the client pushes, the server is *downloading*. +""" + + +# +# Stuff so that we don't try to export revisions while we're importing. +# + +_ILEVEL_ATTR = '@hggit_import_level' +"""An attribute that tracks how many "levels deep" we are into importing. + +We set this on the repository object when we're importing and remove it +when we're done. It's not just a bool in case somebody sets up some crazy +recursive hook situation where we start importing inside another import. +""" + + +def importing_enter(repo: hgrepo.IRepo) -> None: + """Call this before you start importing from Git.""" + level = getattr(repo, _ILEVEL_ATTR, 0) + 1 + setattr(repo, _ILEVEL_ATTR, level) + + +def is_importing(repo: hgrepo.IRepo) -> bool: + """Call this to check if you're currently importing.""" + return hasattr(repo, _ILEVEL_ATTR) + + +def importing_exit(repo: hgrepo.IRepo) -> None: + """Call this after you finish importing from Git.""" + level = getattr(repo, _ILEVEL_ATTR) - 1 + if level: + setattr(repo, _ILEVEL_ATTR, level) + else: + delattr(repo, _ILEVEL_ATTR) + + +# +# Export handling. +# + + +def clean_all_refs(refs: dulwich.refs.RefsContainer) -> None: + """Removes all refs from the Git repository.""" + + +def set_head(ui: hgui.ui, repo: GittyRepo, at_name: bytes) -> None: + """Creates a HEAD reference in Git referring to the current HEAD.""" + # By default, we use '@', since that's what will be auto checked out. + current = b'@' + if current not in repo._bookmarks: + current = repo._bookmarks.active or current + + # We'll be moving this (possibly fake) bookmark into Git. + git_current = current + if current == b'@': + # @ is a special keyword in Git, so we can't use it as a bookmark. + git_current = at_name + git_branch = dulwich.refs.LOCAL_BRANCH_PREFIX + git_current + if not dulwich.refs.check_ref_format(git_branch): + # We can't export this ref to Git. Give up. + ui.warn(f'{git_branch!r} is not a valid branch name for Git.'.encode()) + return + try: + # Maybe this is a real bookmark? + hgnode = repo._bookmarks[current] + except KeyError: + # Not a real bookmark. Assume we want the tip of the current branch. + branch = repo.dirstate.branch() + try: + hgnode = repo.branchtip(branch) + except hgerr.RepoLookupError: + # This branch somehow doesn't exist??? + ui.warn(f"{branch!r} doesn't seem to exist?".encode()) + return + hgsha = binascii.hexlify(hgnode) + gitsha = repo.githandler.map_git_get(hgsha) + if not gitsha: + # No Git SHA to match this Hg sha. Give up. + ui.warn(f'revision {hgsha!r} was not exported to Git'.encode()) + return + refs = repo.githandler.git.refs + refs.add_packed_refs({git_branch: gitsha}) + refs.set_symbolic_ref(b'HEAD', git_branch) + + +def fix_refs(ui: hgui.ui, repo: GittyRepo) -> None: + """After a git export, fix up the refs. + + This ensures that there are no leftover refs from older, removed bookmarks + and that there is a proper HEAD set so that cloning works. + """ + refs = repo.githandler.git.refs + # dump to allkeys so we explicitly are iterating over a snapshot + # and not over something while we mutate + for ref in refs.allkeys(): + refs.remove_if_equals(ref, None) + repo.githandler.export_hg_tags() + repo.githandler.update_references() + default_branch_name = ui.config( + b'hggit-serve', b'default-branch', b'default' + ) + set_head(ui, repo, default_branch_name) + + +# +# Hooks +# + + +def _export_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: + """Maybe exports the repository to get after we get new revs.""" + if not is_gitty(repo): + return + auto_export = ui.config(b'hggit-serve', b'auto-export') + if auto_export == b'never': + return + if auto_export == b'always' or git_handler.has_gitrepo(repo): + if is_importing(repo): + ui.note(b'currently importing revs from git; not exporting\n') + return + repo.githandler.export_commits() + fix_refs(ui, repo) + + +def _fix_refs_hook(ui: hgui.ui, repo: hgrepo.IRepo, **__: object) -> None: + """Exports to Git and sets up for serving. See ``_fix_refs``.""" + if not is_gitty(repo): + return + fix_refs(ui, repo) + + +# +# Interfacing with Mercurial +# + + +def uipopulate(ui: hgui.ui) -> None: + # Fix up our tags after a Git export. + ui.setconfig( + b'hooks', b'post-git-export.__gitserve_add_tag__', _fix_refs_hook + ) + # Whenever we get new revisions, export them to the Git repository. + ui.setconfig(b'hooks', b'txnclose.__gitserve_export__', _export_hook) + # Don't step on ourselves when importing data from Git. + ui.setconfig( + b'hooks', + b'pre-git-import.__gitserve_suppress_export__', + lambda _, repo, **__: importing_enter(repo), + ) + ui.setconfig( + b'hooks', + b'post-git-import.__gitserve_suppress_export__', + lambda _, repo, **__: importing_exit(repo), + )
