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),
+    )