view src/hgext3rd/hggit_serve/_export.py @ 14:959ef686193f

Add user-facing documentation.
author Paul Fisher <paul@pfish.zone>
date Fri, 20 Feb 2026 21:05:31 -0500
parents 00bdfac5416c
children 78ea1ec94be5
line wrap: on
line source

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)


def import_all(repo: GittyRepo, command: bytes = b'(unknown)') -> None:
    _importing_enter(repo)
    try:
        gh = repo.githandler
        gh.import_git_objects(
            command, remote_names=(), refs=gh.git.refs.as_dict()
        )
    finally:
        _importing_exit(repo)


#
# Export handling.
#


def _clean_all_refs(refs: dulwich.refs.RefsContainer) -> None:
    """Removes all refs from the Git repository."""
    # 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)


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.
    """
    _clean_all_refs(repo.githandler.git.refs)
    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
#

_AX_ALWAYS = b'always'
_AX_DEFAULT = b'default'
_AX_NEVER = b'never'


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 == _AX_NEVER:
        return
    if auto_export == _AX_ALWAYS or git_handler.has_gitrepo(repo):
        if auto_export not in (None, _AX_ALWAYS, _AX_DEFAULT):
            ui.warn(
                b'unrecognized auto-export setting %s; using %s',
                auto_export,
                _AX_DEFAULT,
            )
        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,
        source=b'hggit_serve',
    )
    # Whenever we get new revisions, export them to the Git repository.
    ui.setconfig(
        b'hooks',
        b'txnclose.__gitserve_export__',
        _export_hook,
        source=b'hggit_serve',
    )
    # 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),
        source=b'hggit_serve',
    )
    ui.setconfig(
        b'hooks',
        b'post-git-import.__gitserve_suppress_export__',
        lambda _, repo, **__: _importing_exit(repo),
        source=b'hggit-serve',
    )