view hgext/git/__init__.py @ 46527:018d622e814d

test-copies: reinstall initial identical (empty) files for chained copied This effectively back out changeset deeb215be337. Changeset deeb215be33 does not really include a justification for its change and make mes uncomfortable. I have been thinking about it and they are two options: - either having empty/full files does not make a difference, and deeb215be337 is a gratuitous changes. - either having empty/full files do make a difference and deeb215be33 silently change the test coverage. In such situation if we want the "not empty" case to be tested, we should add new cases to cover them In practice, we know that the "file content did not change, but merge still need to create a new filenode" case exists (for example if merging result in similar content but both parent of the file need to be recorded), and that such case are easy to miss/mess-up in the tests. Having all the file using the same (empty) content was done on purpose to increase the coverage of such corner case. As a result I am reinstalling the previous test situation. To increase the coverage of some case involving content-merge in test-copies-chain-merge.t, we will add a new, dedicated, cases later in this series, once various cleanup and test improvement have been set in place. This changeset starts with reinstalling the previous situation as (1) it is more fragile, so I am more confided getting it back in the initial situation, (2) I have specific test further down the line that are base on these one. The next changeset will slightly alter the test to use non-empty files for these tests (with identical content). It should help to make the initial intent "merge file with identical content" clearer. I am still using a two steps (backout, then change content) approach to facilitate careful validation of the output change. Doing so has a large impact on the output of the "copy info in changeset extra" variant added in 5e72827dae1e (2 changesets after deeb215be33). It seems to highlight various breakage when merge without content change are involved, this is a good example of why we want to explicitly test theses cases. Because the different -do- matters a lot. Fixing the "copy info in changeset extra" is not a priority here. Because (1) this changeset does not break anything, it only highlight that they were always broken. (2) the only people using "copy info in changeset extra" do not have merge. Differential Revision: https://phab.mercurial-scm.org/D9587
author Pierre-Yves David <pierre-yves.david@octobus.net>
date Thu, 10 Dec 2020 14:25:36 +0100
parents c7c1efdfd4de
children e9901d01d135
line wrap: on
line source

"""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

from mercurial.i18n import _

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

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

# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
# be specifying the version(s) of Mercurial they are tested with, or
# leave the attribute unspecified.
testedwith = b'ships-with-hg-core'

configtable = {}
configitem = registrar.configitem(configtable)
# git.log-index-cache-miss: internal knob for testing
configitem(
    b"git",
    b"log-index-cache-miss",
    default=False,
)

getversion = gitutil.pygit2_version


# 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 = gitutil.get_pygit2().Repository(
            os.path.normpath(os.path.join(path, b'..', b'.git'))
        )
        self._progress_factory = lambda *args, **kwargs: None
        self._logfn = lambda x: 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._logfn, 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 b'git' in requirements:
        if not os.path.exists(os.path.join(storebasepath, b'..', b'.git')):
            raise error.Abort(
                _(
                    b'repository specified git format in '
                    b'.hg/requires but has no .git directory'
                )
            )
        # Check for presence of pygit2 only here. The assumption is that we'll
        # run this code iff we'll later need pygit2.
        if gitutil.get_pygit2() is None:
            raise error.Abort(
                _(
                    b'the git extension requires the Python '
                    b'pygit2 library to be installed'
                )
            )

        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'requires'), 'wb') as f:
        f.write(b'git\n')


_BMS_PREFIX = 'refs/heads/'


class gitbmstore(object):
    def __init__(self, gitrepo):
        self.gitrepo = gitrepo
        self._aclean = True
        self._active = gitrepo.references['HEAD']  # git head, not mark

    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 gitutil.get_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):
        githead = mark is not None and (_BMS_PREFIX + mark) or None
        if githead is not None and githead not in self.gitrepo.references:
            raise AssertionError(b'bookmark %s does not exist!' % mark)

        self._active = githead
        self._aclean = False

    def _writeactive(self):
        if self._aclean:
            return
        self.gitrepo.references.create('HEAD', self._active, True)
        self._aclean = True

    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 checkconflict(self, mark, force=False, target=None):
        githead = _BMS_PREFIX + mark
        cur = self.gitrepo.references['HEAD']
        if githead in self.gitrepo.references and not force:
            if target:
                if self.gitrepo.references[githead] == target and target == cur:
                    # re-activating a bookmark
                    return []
                # moving a bookmark - forward?
                raise NotImplementedError
            raise error.Abort(
                _(b"bookmark '%s' already exists (use -f to force)") % mark
            )
        if len(mark) > 3 and not force:
            try:
                shadowhash = scmutil.isrevsymbol(self._repo, mark)
            except error.LookupError:  # ambiguous identifier
                shadowhash = False
            if shadowhash:
                self._repo.ui.warn(
                    _(
                        b"bookmark %s matches a changeset hash\n"
                        b"(did you leave a -r out of an 'hg bookmark' "
                        b"command?)\n"
                    )
                    % mark
                )
        return []


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 repo.local() and isinstance(repo.store, gitstore):
        orig = repo.__class__
        repo.store._progress_factory = repo.ui.makeprogress
        if ui.configbool(b'git', b'log-index-cache-miss'):
            repo.store._logfn = repo.ui.warn

        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)
                if ret is None:
                    # there was nothing to commit, so we should skip
                    # the index fixup logic we'd otherwise do.
                    return None
                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 _featuresetup(ui, supported):
    # don't die on seeing a repo with the git requirement
    supported |= {b'git'}


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')]
    )
    localrepo.featuresetupfuncs.add(_featuresetup)