hgext/git/__init__.py
author Augie Fackler <augie@google.com>
Tue, 11 Feb 2020 00:44:59 -0500
changeset 44489 ad718271a9eb
child 44496 ec54b3d2af0b
permissions -rw-r--r--
git: skeleton of a new extension to _directly_ operate on git repos This is based in part of work I did years ago in hgit, but it's mostly new code since I'm using pygit2 instead of dulwich and the hg storage interfaces have improved. Some cleanup of old hgit code by Pulkit, which I greatly appreciate. test-git-interop.t does not cover a whole lot of cases, but it passes. It includes status, diff, making a new commit, and `hg annotate` working on the git repository. This is _not_ (yet) production quality code: this is an experiment. Known technical debt lurking in this implementation: * Writing bookmarks just totally ignores transactions. * The way progress is threaded down into the gitstore is awful. * Ideally we'd find a way to incrementally reindex DAGs. I'm not sure how to do that efficiently, so we might need a "known only fast-forwards" mode on the DAG indexer for use on `hg commit` and friends. * We don't even _try_ to do anything reasonable for `hg pull` or `hg push`. * Mercurial need an interface for the changelog type. Tests currently require git 2.24 as far as I'm aware: `git status` has some changed output that I didn't try and handle in a compatible way. This patch has produced some interesting cleanups, most recently on the manifest type. I expect continuing down this road will produce other meritorious cleanups throughout our code. Differential Revision: https://phab.mercurial-scm.org/D6734

"""grant Mercurial the ability to operate on Git repositories. (EXPERIMENTAL)

This is currently super experimental. It probably will consume your
firstborn a la Rumpelstiltskin, etc.
"""

from __future__ import absolute_import

import os

import pygit2

from mercurial.i18n import _

from mercurial import (
    commands,
    error,
    extensions,
    localrepo,
    pycompat,
    store,
    util,
)

from . import (
    dirstate,
    gitlog,
    gitutil,
    index,
)


# TODO: extract an interface for this in core
class gitstore(object):  # store.basicstore):
    def __init__(self, path, vfstype):
        self.vfs = vfstype(path)
        self.path = self.vfs.base
        self.createmode = store._calcmode(self.vfs)
        # above lines should go away in favor of:
        # super(gitstore, self).__init__(path, vfstype)

        self.git = pygit2.Repository(
            os.path.normpath(os.path.join(path, b'..', b'.git'))
        )
        self._progress_factory = lambda *args, **kwargs: None

    @util.propertycache
    def _db(self):
        # We lazy-create the database because we want to thread a
        # progress callback down to the indexing process if it's
        # required, and we don't have a ui handle in makestore().
        return index.get_index(self.git, self._progress_factory)

    def join(self, f):
        """Fake store.join method for git repositories.

        For the most part, store.join is used for @storecache
        decorators to invalidate caches when various files
        change. We'll map the ones we care about, and ignore the rest.
        """
        if f in (b'00changelog.i', b'00manifest.i'):
            # This is close enough: in order for the changelog cache
            # to be invalidated, HEAD will have to change.
            return os.path.join(self.path, b'HEAD')
        elif f == b'lock':
            # TODO: we probably want to map this to a git lock, I
            # suspect index.lock. We should figure out what the
            # most-alike file is in git-land. For now we're risking
            # bad concurrency errors if another git client is used.
            return os.path.join(self.path, b'hgit-bogus-lock')
        elif f in (b'obsstore', b'phaseroots', b'narrowspec', b'bookmarks'):
            return os.path.join(self.path, b'..', b'.hg', f)
        raise NotImplementedError(b'Need to pick file for %s.' % f)

    def changelog(self, trypending):
        # TODO we don't have a plan for trypending in hg's git support yet
        return gitlog.changelog(self.git, self._db)

    def manifestlog(self, repo, storenarrowmatch):
        # TODO handle storenarrowmatch and figure out if we need the repo arg
        return gitlog.manifestlog(self.git, self._db)

    def invalidatecaches(self):
        pass

    def write(self, tr=None):
        # normally this handles things like fncache writes, which we don't have
        pass


def _makestore(orig, requirements, storebasepath, vfstype):
    if os.path.exists(
        os.path.join(storebasepath, b'this-is-git')
    ) and os.path.exists(os.path.join(storebasepath, b'..', b'.git')):
        return gitstore(storebasepath, vfstype)
    return orig(requirements, storebasepath, vfstype)


class gitfilestorage(object):
    def file(self, path):
        if path[0:1] == b'/':
            path = path[1:]
        return gitlog.filelog(self.store.git, self.store._db, path)


def _makefilestorage(orig, requirements, features, **kwargs):
    store = kwargs['store']
    if isinstance(store, gitstore):
        return gitfilestorage
    return orig(requirements, features, **kwargs)


def _setupdothg(ui, path):
    dothg = os.path.join(path, b'.hg')
    if os.path.exists(dothg):
        ui.warn(_(b'git repo already initialized for hg\n'))
    else:
        os.mkdir(os.path.join(path, b'.hg'))
        # TODO is it ok to extend .git/info/exclude like this?
        with open(
            os.path.join(path, b'.git', b'info', b'exclude'), 'ab'
        ) as exclude:
            exclude.write(b'\n.hg\n')
    with open(os.path.join(dothg, b'this-is-git'), 'wb') as f:
        pass
    with open(os.path.join(dothg, b'requirements'), 'wb') as f:
        f.write(b'git\n')


_BMS_PREFIX = 'refs/heads/'


class gitbmstore(object):
    def __init__(self, gitrepo):
        self.gitrepo = gitrepo

    def __contains__(self, name):
        return (
            _BMS_PREFIX + pycompat.fsdecode(name)
        ) in self.gitrepo.references

    def __iter__(self):
        for r in self.gitrepo.listall_references():
            if r.startswith(_BMS_PREFIX):
                yield pycompat.fsencode(r[len(_BMS_PREFIX) :])

    def __getitem__(self, k):
        return (
            self.gitrepo.references[_BMS_PREFIX + pycompat.fsdecode(k)]
            .peel()
            .id.raw
        )

    def get(self, k, default=None):
        try:
            if k in self:
                return self[k]
            return default
        except pygit2.InvalidSpecError:
            return default

    @property
    def active(self):
        h = self.gitrepo.references['HEAD']
        if not isinstance(h.target, str) or not h.target.startswith(
            _BMS_PREFIX
        ):
            return None
        return pycompat.fsencode(h.target[len(_BMS_PREFIX) :])

    @active.setter
    def active(self, mark):
        raise NotImplementedError

    def names(self, node):
        r = []
        for ref in self.gitrepo.listall_references():
            if not ref.startswith(_BMS_PREFIX):
                continue
            if self.gitrepo.references[ref].peel().id.raw != node:
                continue
            r.append(pycompat.fsencode(ref[len(_BMS_PREFIX) :]))
        return r

    # Cleanup opportunity: this is *identical* to core's bookmarks store.
    def expandname(self, bname):
        if bname == b'.':
            if self.active:
                return self.active
            raise error.RepoLookupError(_(b"no active bookmark"))
        return bname

    def applychanges(self, repo, tr, changes):
        """Apply a list of changes to bookmarks
        """
        # TODO: this should respect transactions, but that's going to
        # require enlarging the gitbmstore to know how to do in-memory
        # temporary writes and read those back prior to transaction
        # finalization.
        for name, node in changes:
            if node is None:
                self.gitrepo.references.delete(
                    _BMS_PREFIX + pycompat.fsdecode(name)
                )
            else:
                self.gitrepo.references.create(
                    _BMS_PREFIX + pycompat.fsdecode(name),
                    gitutil.togitnode(node),
                    force=True,
                )


def init(orig, ui, dest=b'.', **opts):
    if opts.get('git', False):
        path = os.path.abspath(dest)
        # TODO: walk up looking for the git repo
        _setupdothg(ui, path)
        return 0
    return orig(ui, dest=dest, **opts)


def reposetup(ui, repo):
    if isinstance(repo.store, gitstore):
        orig = repo.__class__
        repo.store._progress_factory = repo.ui.makeprogress

        class gitlocalrepo(orig):
            def _makedirstate(self):
                # TODO narrow support here
                return dirstate.gitdirstate(
                    self.ui, self.vfs.base, self.store.git
                )

            def commit(self, *args, **kwargs):
                ret = orig.commit(self, *args, **kwargs)
                tid = self.store.git[gitutil.togitnode(ret)].tree.id
                # DANGER! This will flush any writes staged to the
                # index in Git, but we're sidestepping the index in a
                # way that confuses git when we commit. Alas.
                self.store.git.index.read_tree(tid)
                self.store.git.index.write()
                return ret

            @property
            def _bookmarks(self):
                return gitbmstore(self.store.git)

        repo.__class__ = gitlocalrepo
    return repo


def extsetup(ui):
    extensions.wrapfunction(localrepo, b'makestore', _makestore)
    extensions.wrapfunction(localrepo, b'makefilestorage', _makefilestorage)
    # Inject --git flag for `hg init`
    entry = extensions.wrapcommand(commands.table, b'init', init)
    entry[1].extend(
        [(b'', b'git', None, b'setup up a git repository instead of hg')]
    )