Edgewall Software

Ticket #10411: T10411-hglib.diff

File T10411-hglib.diff, 35.8 KB (added by Peter Suter, 7 years ago)
  • setup.py

    # HG changeset patch
    # User Peter Suter <petsuter@gmail.com>
    # Date 1427558864 -3600
    #      Sat Mar 28 17:07:44 2015 +0100
    # Branch trunk
    # Node ID 3a593d15e9ef8d9df83eecd25cfbc9de083aa29b
    # Parent  8cea232f7455a89123785c60111c79aa2e0cac63
    Naive Mercurial connector using Mercurial:PythonHglib.
    (see #10411)
    
    diff --git a/setup.py b/setup.py
    a b  
    9797        'babel': ['Babel>=0.9.5'],
    9898        'mysql': ['MySQL-python >= 1.2.2'],
    9999        'postgresql': ['psycopg2 >= 2.0'],
     100        'hglib': ['python-hglib'],
    100101        'pygments': ['Pygments>=1.0'],
    101102        'rest': ['docutils>=0.3.9'],
    102103        'textile': ['textile>=2.0'],
     
    148149        tracopt.ticket.commit_updater = tracopt.ticket.commit_updater
    149150        tracopt.ticket.deleter = tracopt.ticket.deleter
    150151        tracopt.versioncontrol.git.git_fs = tracopt.versioncontrol.git.git_fs
     152        tracopt.versioncontrol.hg.hg_fs = tracopt.versioncontrol.hg.hg_fs[hglib]
    151153        tracopt.versioncontrol.svn.svn_fs = tracopt.versioncontrol.svn.svn_fs
    152154        tracopt.versioncontrol.svn.svn_prop = tracopt.versioncontrol.svn.svn_prop
    153155    """,
  • new file tracopt/versioncontrol/hg/hg_fs.py

    diff --git a/tracopt/versioncontrol/hg/__init__.py b/tracopt/versioncontrol/hg/__init__.py
    new file mode 100644
    diff --git a/tracopt/versioncontrol/hg/hg_fs.py b/tracopt/versioncontrol/hg/hg_fs.py
    new file mode 100644
    - +  
     1# -*- coding: utf-8 -*-
     2#
     3# Copyright (C) 2017 Edgewall Software
     4# All rights reserved.
     5#
     6# This software is licensed as described in the file COPYING, which
     7# you should have received as part of this distribution. The terms
     8# are also available at http://trac.edgewall.org/wiki/TracLicense.
     9#
     10# This software consists of voluntary contributions made by many
     11# individuals. For the exact contribution history, see the revision
     12# history and logs, available at http://trac.edgewall.org/log/.
     13
     14from datetime import datetime
     15import hglib
     16import os.path
     17import StringIO
     18
     19from trac.core import *
     20from trac.util import TracError
     21from trac.util.datefmt import utc
     22from trac.util.text import to_unicode, exception_to_unicode
     23from trac.util.translation import _
     24from trac.versioncontrol.api import Changeset, Node, Repository, \
     25                                    IRepositoryConnector, InvalidRepository,\
     26                                    NoSuchChangeset, NoSuchNode
     27
     28
     29class HgConnector(Component):
     30
     31    implements(IRepositoryConnector)
     32
     33    required = False
     34
     35    # IRepositoryConnector methods
     36
     37    def get_supported_types(self):
     38        yield ('hg', 8)
     39
     40    def get_repository(self, type, dir, params):
     41        """HgRepository factory method"""
     42        assert type == 'hg'
     43
     44        repos = HgRepository(self.env, dir, params, self.log)
     45
     46        self.required = True
     47        return repos
     48
     49
     50class HgRepository(Repository):
     51    """Hg repository"""
     52
     53    def __init__(self, env, path, params, log):
     54        self.env = env
     55        self.params = params
     56        try:
     57            self.hg = hglib.open(path)
     58        except hglib.error.ServerError:
     59            raise InvalidRepository(
     60                _("%(path)s does not appear to be a Mercurial repository.",
     61                  path=path))
     62
     63        Repository.__init__(self, 'hg:' + path, self.params, log)
     64
     65    def close(self):
     66        self.hg.close()
     67        self.hg = None
     68
     69    def get_youngest_rev(self):
     70        tip = self.hg.tip()
     71        if tip.rev == '-1':
     72            return None
     73        return tip.node
     74
     75    def get_path_history(self, path, rev=None, limit=None):
     76        raise TracError(_("Unsupported \"Show only adds and deletes\""))
     77
     78    def get_oldest_rev(self):
     79        oldest = self.hg['0']
     80        if oldest.rev() == -1:
     81            return None
     82        return oldest.node()
     83
     84    def normalize_path(self, path):
     85        # manifest and status use inconsistent path sep (on Windows)
     86        if path:
     87            path = path.replace('\\', '/')
     88        return path and path.strip('/') or '/'
     89
     90    def _changectx(self, rev):
     91        try:
     92            return self.hg[rev]
     93        except ValueError:
     94            raise NoSuchChangeset(rev)
     95
     96    def normalize_rev(self, rev):
     97        if not rev:
     98            return self.get_youngest_rev()
     99        return self._changectx(rev).node()
     100
     101    def display_rev(self, rev):
     102        if not rev:
     103            rev = self.youngest_rev
     104        try:
     105            changectx = self.hg[rev]
     106        except ValueError:
     107            return None
     108        return "%s:%s" % (changectx.rev(), changectx.node()[:12])
     109           
     110    def short_rev(self, rev):
     111        if not rev:
     112            rev = self.youngest_rev
     113        try:
     114            changectx = self.hg[rev]
     115        except ValueError:
     116            return None
     117        return changectx.rev()
     118
     119    def get_node(self, path, rev=None, changectx=None):
     120        return HgNode(self, path, rev, self.log, changectx)
     121
     122    def get_quickjump_entries(self, rev):
     123        for name, rev, node in self.hg.branches():
     124            yield 'branches', name, '/', node
     125        for name, rev, node in self.hg.bookmarks()[0]:
     126            yield 'bookmarks', name, '/', node
     127        for name, rev, node, islocal in self.hg.tags():
     128            yield 'tags', name, '/', node
     129
     130    def get_path_url(self, path, rev):
     131        return self.params.get('url')
     132
     133    def get_changesets(self, start, stop):
     134        revrange = "reverse(date('>%s') and not date('>%s'))" % (str(start),
     135                                                                 str(stop))
     136        for entry in self.hg.log(revrange=revrange):
     137            yield self.get_changeset(entry.node)
     138
     139    def get_changeset(self, rev):
     140        """HgChangeset factory method"""
     141        return HgChangeset(self, rev)
     142
     143    def get_changeset_uid(self, rev):
     144        return self.normalize_rev(rev)
     145
     146    def get_changes(self, old_path, old_rev, new_path, new_rev,
     147                    ignore_ancestry=0):
     148        # TODO: handle renames/copies, ignore_ancestry
     149        if old_path != new_path:
     150            raise TracError(_("Not supported in hg_fs"))
     151
     152        old_changectx = self._changectx(old_rev)
     153        new_changectx = self._changectx(new_rev)
     154        revset = '%s:%s' % (old_rev, new_rev)
     155        include = None
     156        if new_path != '/':
     157            include = os.path.join(self.hg.root(), new_path)
     158        for action, path in self.hg.status(revset, include=include):
     159            change = HgChangeset.action_map[action]
     160
     161            old_node = None
     162            new_node = None
     163            if change != Changeset.ADD:
     164                old_node = self.get_node(path, old_rev, old_changectx)
     165            if change != Changeset.DELETE:
     166                new_node = self.get_node(path, new_rev, new_changectx)
     167
     168            yield old_node, new_node, Node.FILE, change
     169
     170    def next_rev(self, rev, path=''):
     171        revset = 'descendants(children(%s))' % rev
     172        if path:
     173            fullpath = os.path.join(self.hg.root(), path)
     174            entries = self.hg.log(revrange=revset, limit=1,
     175                                  files=[fullpath])
     176        else:
     177            entries = self.hg.log(revrange=revset, limit=1)
     178        for entry in entries:
     179            return entry.node
     180
     181    def previous_rev(self, rev, path=''):
     182        revset = 'reverse(ancestors(parents(%s)))' % rev
     183        if path:
     184            fullpath = os.path.join(self.hg.root(), path)
     185            entries = self.hg.log(revrange=revset, limit=1,
     186                                  files=[fullpath])
     187        else:
     188            entries = self.hg.log(revrange=revset, limit=1)
     189        for entry in entries:
     190            return entry.node
     191
     192    def parent_revs(self, rev):
     193        return [changectx.node() for changectx in self.hg[rev].parents()
     194                                 if changectx.rev() != -1]
     195
     196    def child_revs(self, rev):
     197        return [changectx.node() for changectx in self.hg[rev].children()]
     198
     199    def rev_older_than(self, rev1, rev2):
     200        return self.short_rev(rev1) < self.short_rev(rev2)
     201
     202
     203class HgNode(Node):
     204
     205    def __init__(self, repos, path, rev, log, changectx=None):
     206        self.log = log
     207        self.repos = repos
     208        self.fs_size = None
     209        self.created_path = path
     210
     211        if rev:
     212            rev = repos.normalize_rev(to_unicode(rev))
     213        else:
     214            rev = repos.youngest_rev
     215
     216        self.created_rev = rev
     217
     218        if changectx is not None:
     219            self.changectx = changectx
     220        elif rev is None:
     221            self.changectx = repos._changectx(-1)
     222        else:
     223            self.changectx = repos._changectx(rev)
     224
     225        kind = Node.DIRECTORY
     226        p = path.strip('/')
     227        self.fullpath = os.path.join(repos.hg.root(), p)
     228        if p:  # ie. not the root-tree
     229            if not rev:
     230                raise NoSuchNode(path, rev)
     231
     232            if repos.normalize_path(p) in self.changectx.manifest():
     233                kind = Node.FILE
     234
     235            # fix-up to the last commit-rev that touched this node
     236            for entry in self.repos.hg.log(revrange='reverse(::%s)' % rev, limit=1,
     237                                           files=[self.fullpath]):
     238                self.created_rev = entry.node
     239                break
     240            else:
     241                raise NoSuchNode(path, rev)
     242
     243        Node.__init__(self, repos, path, rev, kind)
     244
     245    def get_content(self):
     246        if not self.isfile:
     247            return None
     248
     249        return StringIO.StringIO(self.repos.hg.cat([self.fullpath],
     250                                                   rev=self.rev))
     251
     252    def get_properties(self):
     253        return {}
     254
     255    def get_annotations(self):
     256        if not self.isfile:
     257            return
     258
     259        return [self.repos.normalize_rev(rev) for rev, contents in \
     260                self.repos.hg.annotate([self.fullpath], rev=self.rev)]
     261
     262    def get_entries(self):
     263        if not self.rev:
     264            return
     265        if not self.isdir:
     266            return
     267
     268        parent = self.path.lstrip('/')
     269        parent_len = len(parent)
     270        if parent != '':
     271            parent += '/'
     272            parent_len += 1
     273
     274        entries = set()
     275        for path in self.changectx.manifest():
     276            if not path:
     277                continue
     278            if not path.startswith(parent):
     279                continue
     280
     281            pos = path.find('/', parent_len)
     282            if pos == -1:
     283                entries.add(path)
     284            else:
     285                entries.add(path[:pos])
     286
     287        for entry in sorted(entries):
     288            yield HgNode(self.repos, entry, self.rev, self.log, self.changectx)
     289
     290    def get_content_type(self):
     291        if self.isdir:
     292            return None
     293
     294        return ''
     295
     296    def get_content_length(self):
     297        if not self.isfile:
     298            return None
     299
     300        if self.fs_size is None:
     301            # TODO inefficient?
     302            self.fs_size = len(self.repos.hg.cat([self.fullpath],
     303                                                 rev=self.rev))
     304
     305        return self.fs_size
     306
     307    def get_history(self, limit=None):
     308        if not self.rev:
     309            return
     310        # TODO: follow renames/copies?
     311        entries = self.repos.hg.log(revrange='%s:0' % self.rev, limit=limit,
     312                                    files=[self.fullpath])
     313        if entries:
     314            for entry in entries[:-1]:
     315                yield (self.path, entry.node, Changeset.EDIT)
     316            yield (self.path, entries[-1].node, Changeset.ADD)
     317
     318    def get_last_modified(self):
     319        return self.changectx.date()
     320
     321
     322class HgChangeset(Changeset):
     323    """A Mercurial changeset in the Mercurial repository.
     324
     325    Corresponds to a Mercurial commit.
     326    """
     327
     328    action_map = { # see also "hg status"
     329        'M': Changeset.EDIT, # modified
     330        'A': Changeset.ADD,
     331        'R': Changeset.DELETE, # removed
     332    }
     333
     334    def __init__(self, repos, rev):
     335        try:
     336            self.changectx = repos.hg[rev]
     337        except ValueError:
     338            raise NoSuchChangeset(rev)
     339
     340        msg = self.changectx.description()
     341        author = self.changectx.author()
     342        date = utc.localize(self.changectx.date())
     343        Changeset.__init__(self, repos, rev=rev, message=msg, author=author,
     344                           date=date)
     345
     346    def get_properties(self):
     347        properties = {}
     348
     349        parents = list(c.node() for c in self.changectx.parents()
     350                                if changectx.rev() != -1)
     351        if parents:
     352            properties['Parents'] = parents
     353
     354        children = list(c.node() for c in self.changectx.children())
     355        if children:
     356            properties['Children'] = children
     357
     358        branch = self.changectx.branch()
     359        if branch:
     360            properties['Branches'] = [branch]
     361
     362        return properties
     363
     364    def get_changes(self):
     365        edits, adds, dels = self.changectx.status()[:3]
     366
     367        # TODO find actual base revision of each change
     368        base_rev = self.changectx.p1().node()
     369        if base_rev == -1:
     370            base_rev = None
     371
     372        for path in edits:
     373            base_path = path # TODO find actual base path?
     374            yield path, Node.FILE, Changeset.EDIT, base_path, base_rev
     375        for path in adds:
     376            yield path, Node.FILE, Changeset.ADD, None, None
     377        for path in dels:
     378            base_path = path # TODO find actual base path?
     379            yield path, Node.FILE, Changeset.DELETE, base_path, base_rev
     380
     381    def get_branches(self):
     382        current_branch = self.changectx.branch()
     383        current_rev = self.repos.normalize_rev(self.rev)
     384        for name, rev, node in self.repos.hg.branches():
     385            if current_branch == name:
     386                node = self.repos.normalize_rev(node)
     387                yield name, node == current_rev
     388
     389    def get_tags(self):
     390        return self.changectx.tags()
     391
     392    def get_bookmarks(self):
     393        return self.changectx.bookmarks()
  • new file tracopt/versioncontrol/hg/tests/__init__.py

    diff --git a/tracopt/versioncontrol/hg/tests/__init__.py b/tracopt/versioncontrol/hg/tests/__init__.py
    new file mode 100644
    - +  
     1# -*- coding: utf-8 -*-
     2#
     3# Copyright (C) 2017 Edgewall Software
     4# All rights reserved.
     5#
     6# This software is licensed as described in the file COPYING, which
     7# you should have received as part of this distribution. The terms
     8# are also available at http://trac.edgewall.org/wiki/TracLicense.
     9#
     10# This software consists of voluntary contributions made by many
     11# individuals. For the exact contribution history, see the revision
     12# history and logs, available at http://trac.edgewall.org/log/.
     13
     14import unittest
     15
     16from tracopt.versioncontrol.hg.tests import hg_fs
     17
     18
     19def test_suite():
     20    suite = unittest.TestSuite()
     21    suite.addTest(hg_fs.test_suite())
     22    return suite
     23
     24
     25if __name__ == '__main__':
     26    unittest.main(defaultTest='test_suite')
  • new file tracopt/versioncontrol/hg/tests/hg_fs.py

    diff --git a/tracopt/versioncontrol/hg/tests/hg_fs.py b/tracopt/versioncontrol/hg/tests/hg_fs.py
    new file mode 100644
    - +  
     1# -*- coding: utf-8 -*-
     2#
     3# Copyright (C) 2017 Edgewall Software
     4# All rights reserved.
     5#
     6# This software is licensed as described in the file COPYING, which
     7# you should have received as part of this distribution. The terms
     8# are also available at http://trac.edgewall.org/wiki/TracLicense.
     9#
     10# This software consists of voluntary contributions made by many
     11# individuals. For the exact contribution history, see the revision
     12# history and logs, available at http://trac.edgewall.org/log/.
     13
     14import io
     15import os
     16import sys
     17import shutil
     18import unittest
     19from datetime import datetime, timedelta
     20from subprocess import PIPE
     21
     22from trac.core import TracError
     23from trac.test import EnvironmentStub, MockRequest, locate, mkdtemp
     24from trac.tests.compat import rmtree
     25from trac.util import create_file, makedirs
     26from trac.util.compat import Popen, close_fds
     27from trac.util.datefmt import to_timestamp, utc
     28from trac.util.text import to_utf8
     29from trac.versioncontrol.api import Changeset, DbRepositoryProvider, \
     30                                    InvalidRepository, Node, \
     31                                    NoSuchChangeset, NoSuchNode, \
     32                                    RepositoryManager
     33from trac.versioncontrol.web_ui.browser import BrowserModule
     34from trac.versioncontrol.web_ui.log import LogModule
     35from tracopt.versioncontrol.hg.hg_fs import HgRepository
     36
     37
     38class HgCommandMixin(object):
     39
     40    hg_bin = locate('hg')
     41
     42    def _hg_commit(self, *args, **kwargs):
     43        args2 = ['commit', '--config', 'ui.username=Joe <joe@example.com>']
     44        if 'date' in kwargs:
     45            args2.append('--date')
     46            args2.append(str(kwargs.pop('date')))
     47        args2 += args
     48        return self._hg(*args2, **kwargs)
     49
     50    def _spawn_hg(self, *args, **kwargs):
     51        args = map(to_utf8, (self.hg_bin,) + args)
     52        kwargs.setdefault('stdin', PIPE)
     53        kwargs.setdefault('stdout', PIPE)
     54        kwargs.setdefault('stderr', PIPE)
     55        kwargs.setdefault('cwd', self.repos_path)
     56        return Popen(args, close_fds=close_fds, **kwargs)
     57
     58    def _hg(self, *args, **kwargs):
     59        with self._spawn_hg(*args, **kwargs) as proc:
     60            stdout, stderr = proc.communicate()
     61        self.assertEqual(0, proc.returncode,
     62                         'hg exits with %r, args %r, kwargs %r, stdout %r, '
     63                         'stderr %r' %
     64                         (proc.returncode, args, kwargs, stdout, stderr))
     65        return proc
     66
     67
     68class BaseTestCase(unittest.TestCase, HgCommandMixin):
     69
     70    def setUp(self):
     71        self.env = EnvironmentStub()
     72        self.repos_path = mkdtemp()
     73
     74    def tearDown(self):
     75        for repos in self._repomgr.get_real_repositories():
     76            repos.close()
     77        self._repomgr.reload_repositories()
     78        self.env.reset_db()
     79        if os.path.isdir(self.repos_path):
     80            rmtree(self.repos_path)
     81
     82    @property
     83    def _repomgr(self):
     84        return RepositoryManager(self.env)
     85
     86    @property
     87    def _dbrepoprov(self):
     88        return DbRepositoryProvider(self.env)
     89
     90    def _add_repository(self, reponame='hgrepos'):
     91        path = self.repos_path
     92        self._dbrepoprov.add_repository(reponame, path, 'hg')
     93
     94    def _hg_init(self, data=True):
     95        self._hg('init')
     96        if data:
     97            create_file(os.path.join(self.repos_path, '.hgignore'))
     98            self._hg('add', '.hgignore')
     99            self._hg_commit('-m', 'test',
     100                             date=datetime(2001, 1, 29, 16, 39, 56))
     101
     102
     103class SanityCheckingTestCase(BaseTestCase):
     104
     105    def test(self):
     106        self._hg_init()
     107        self._dbrepoprov.add_repository('hgrepos.1', self.repos_path, 'hg')
     108        self._repomgr.get_repository('hgrepos.1')
     109        self._dbrepoprov.add_repository('hgrepos.2', self.repos_path, 'hg')
     110        self._repomgr.get_repository('hgrepos.2')
     111
     112
     113class HistoryTimeRangeTestCase(BaseTestCase):
     114
     115    def test(self):
     116        self._hg_init()
     117        filename = os.path.join(self.repos_path, '.hgignore')
     118        start = datetime(2000, 1, 1, 0, 0, 0, 0, utc)
     119        ts = datetime(2014, 2, 5, 15, 24, 6, 0, utc)
     120        for idx in xrange(3):
     121            create_file(filename, 'commit-%d.txt' % idx)
     122            self._hg_commit('-m', 'commit %d' % idx, date=ts)
     123        self._add_repository()
     124        repos = self._repomgr.get_repository('hgrepos')
     125        repos.sync()
     126
     127        revs = [repos.youngest_rev]
     128        while True:
     129            parents = repos.parent_revs(revs[-1])
     130            if not parents:
     131                break
     132            revs.extend(parents)
     133        self.assertEqual(4, len(revs))
     134
     135        csets = list(repos.get_changesets(start, ts))
     136        self.assertEqual(1, len(csets))
     137        self.assertEqual(revs[-1], csets[0].rev)  # is oldest rev
     138
     139        csets = list(repos.get_changesets(start, ts + timedelta(seconds=1)))
     140        self.assertEqual(revs, [cset.rev for cset in csets])
     141
     142
     143class HgNormalTestCase(BaseTestCase):
     144
     145    def test_get_node(self):
     146        self._hg_init()
     147        self._add_repository()
     148        repos = self._repomgr.get_repository('hgrepos')
     149        rev = repos.youngest_rev
     150        self.assertNotEqual(None, rev)
     151        self.assertEqual(40, len(rev))
     152
     153        self.assertEqual(rev, repos.get_node('/').rev)
     154        self.assertEqual(rev, repos.get_node('/', rev[:7]).rev)
     155        self.assertEqual(rev, repos.get_node('/.hgignore').rev)
     156        self.assertEqual(rev, repos.get_node('/.hgignore', rev[:7]).rev)
     157
     158        self.assertRaises(NoSuchNode, repos.get_node, '/non-existent')
     159        self.assertRaises(NoSuchNode, repos.get_node, '/non-existent', rev[:7])
     160        self.assertRaises(NoSuchNode, repos.get_node, '/non-existent', rev)
     161        self.assertRaises(NoSuchChangeset,
     162                          repos.get_node, '/', 'invalid-revision')
     163        self.assertRaises(NoSuchChangeset,
     164                          repos.get_node, '/.hgignore', 'invalid-revision')
     165        self.assertRaises(NoSuchChangeset,
     166                          repos.get_node, '/non-existent', 'invalid-revision')
     167
     168        self._hg('bookmark', 'abc')
     169        repos.sync()
     170        self.assertEqual(rev, repos.get_node('/', 'abc').rev)
     171        self.assertEqual(rev, repos.get_node('/.hgignore', 'abc').rev)
     172
     173    def test_on_empty_repos(self):
     174        self._hg_init(data=False)
     175        self._add_repository()
     176        repos = self._repomgr.get_repository('hgrepos')
     177        repos.sync()
     178        youngest_rev = repos.youngest_rev
     179        self.assertEqual(None, youngest_rev)
     180        self.assertEqual(None, repos.oldest_rev)
     181        self.assertEqual(None, repos.normalize_rev(''))
     182        self.assertEqual(None, repos.normalize_rev(None))
     183        self.assertEqual(None, repos.display_rev(''))
     184        self.assertEqual(None, repos.display_rev(None))
     185        self.assertEqual(None, repos.short_rev(''))
     186        self.assertEqual(None, repos.short_rev(None))
     187
     188        node = repos.get_node('/', youngest_rev)
     189        self.assertEqual([], list(node.get_entries()))
     190        self.assertEqual([], list(node.get_history()))
     191        self.assertRaises(NoSuchNode, repos.get_node, '/path', youngest_rev)
     192
     193        req = MockRequest(self.env, path_info='/browser/hgrepos')
     194        browser_mod = BrowserModule(self.env)
     195        self.assertTrue(browser_mod.match_request(req))
     196        rv = browser_mod.process_request(req)
     197        self.assertEqual('browser.html', rv[0])
     198        self.assertEqual(None, rv[1]['rev'])
     199
     200        req = MockRequest(self.env, path_info='/log/hgrepos')
     201        log_mod = LogModule(self.env)
     202        self.assertTrue(log_mod.match_request(req))
     203        rv = log_mod.process_request(req)
     204        self.assertEqual('revisionlog.html', rv[0])
     205        self.assertEqual([], rv[1]['items'])
     206
     207
     208class HgRepositoryTestCase(BaseTestCase):
     209
     210    def _create_merge_commit(self):
     211        for idx, branch in enumerate(('alpha', 'beta')):
     212            self._hg('update', 'default')
     213            self._hg('branch', branch)
     214            for n in xrange(2):
     215                filename = 'file-%s-%d.txt' % (branch, n)
     216                create_file(os.path.join(self.repos_path, filename))
     217                self._hg('add', filename)
     218                self._hg_commit('-m', filename,
     219                                 date=datetime(2014, 2, 2, 17, 12,
     220                                               n * 2 + idx))
     221        self._hg('update', 'alpha')
     222        self._hg('merge', 'beta')
     223        self._hg_commit('-m', 'Merge branch "beta" to "alpha"')
     224
     225    def test_invalid_path_raises(self):
     226        self.assertRaises(InvalidRepository, HgRepository, self.env,
     227                          '/the/invalid/path', [], self.env.log)
     228
     229    def test_repository_instance(self):
     230        self._hg_init()
     231        self._add_repository('hgrepos')
     232        self.assertEqual(HgRepository,
     233                         type(self._repomgr.get_repository('hgrepos')))
     234
     235    def test_reset_head(self):
     236        self._hg_init()
     237        create_file(os.path.join(self.repos_path, 'file.txt'), 'text')
     238        self._hg('add', 'file.txt')
     239        self._hg_commit('-m', 'test',
     240                         date=datetime(2014, 2, 2, 17, 12, 18))
     241        self._add_repository('hgrepos')
     242        repos = self._repomgr.get_repository('hgrepos')
     243        repos.sync()
     244        youngest_rev = repos.youngest_rev
     245        entries = list(repos.get_node('').get_history())
     246        self.assertEqual(2, len(entries))
     247        self.assertEqual('', entries[0][0])
     248        self.assertEqual(Changeset.EDIT, entries[0][2])
     249        self.assertEqual('', entries[1][0])
     250        self.assertEqual(Changeset.ADD, entries[1][2])
     251       
     252        self._hg('--config', "'extensions.strip='", 'strip', '--rev', '.')
     253        repos.sync()
     254        new_entries = list(repos.get_node('').get_history())
     255        self.assertEqual(1, len(new_entries))
     256        self.assertEqual(new_entries[0], entries[1])
     257        self.assertNotEqual(youngest_rev, repos.youngest_rev)
     258
     259    def test_tags(self):
     260        self._hg_init()
     261        self._add_repository('hgrepos')
     262        repos = self._repomgr.get_repository('hgrepos')
     263        repos.sync()
     264        self.assertEqual(['default', 'tip'], self._get_quickjump_names(repos))
     265        self._hg('tag', 'v1.0')
     266        repos.sync()
     267        self.assertEqual(['default', 'tip', 'v1.0'],
     268                         self._get_quickjump_names(repos))
     269        self._hg('tag', '--remove', 'v1.0')
     270        repos.sync()
     271        self.assertEqual(['default', 'tip'], self._get_quickjump_names(repos))
     272
     273    def test_bookmarks(self):
     274        self._hg_init()
     275        self._add_repository('hgrepos')
     276        repos = self._repomgr.get_repository('hgrepos')
     277        repos.sync()
     278        self.assertEqual(['default', 'tip'], self._get_quickjump_names(repos))
     279        self._hg('bookmarks', 'alpha')
     280        repos.sync()
     281        self.assertEqual(['default', 'alpha', 'tip'],
     282                         self._get_quickjump_names(repos))
     283        self._hg('bookmark', '-m', 'alpha', 'beta')
     284        repos.sync()
     285        self.assertEqual(['default', 'beta', 'tip'],
     286                         self._get_quickjump_names(repos))
     287        self._hg('bookmark', '-d', 'beta')
     288        repos.sync()
     289        self.assertEqual(['default', 'tip'], self._get_quickjump_names(repos))
     290
     291    def _get_quickjump_names(self, repos):
     292        return list(name for type, name, path, rev
     293                         in repos.get_quickjump_entries('tip'))
     294
     295    def test_changeset_bookmarks_tags(self):
     296        self._hg_init()
     297        self._hg('tag', '0.0.1')
     298        self._hg('tag', '-m', 'Root commit', 'initial', '-r', '0.0.1')
     299        self._hg('bookmark', 'root', '-r', '0.0.1')
     300        self._hg('bookmark', 'dev', '-r', '0.0.1')
     301        create_file(os.path.join(self.repos_path, 'file.txt'), 'text')
     302        self._hg('add', 'file.txt')
     303        self._hg_commit('-m', 'Summary')
     304        self._hg('bookmark', 'dev', '-i')
     305        self._hg('tag', '0.1.0dev')
     306        self._hg('tag', '0.1.0a', '-r', '0.1.0dev')
     307        self._add_repository('hgrepos')
     308        repos = self._repomgr.get_repository('hgrepos')
     309        repos.sync()
     310
     311        def get_branches(repos, rev):
     312            rev = repos.normalize_rev(rev)
     313            return list(repos.get_changeset(rev).get_branches())
     314
     315        def get_bookmarks(repos, rev):
     316            rev = repos.normalize_rev(rev)
     317            return list(repos.get_changeset(rev).get_bookmarks())
     318
     319        def get_tags(repos, rev):
     320            rev = repos.normalize_rev(rev)
     321            return list(repos.get_changeset(rev).get_tags())
     322
     323        self.assertEqual([('default', False)],
     324                         get_branches(repos, '0.0.1'))
     325        self.assertEqual([('default', True)],
     326                         get_branches(repos, 'tip'))
     327        self.assertEqual(['root'],
     328                         get_bookmarks(repos, '0.0.1'))
     329        self.assertEqual(['dev'], get_bookmarks(repos, '0.1.0dev'))
     330        self.assertEqual(['0.0.1', 'initial'], get_tags(repos, '0.0.1'))
     331        self.assertEqual(['0.0.1', 'initial'], get_tags(repos, 'initial'))
     332        self.assertEqual(['0.1.0a', '0.1.0dev'], get_tags(repos, '0.1.0dev'))
     333
     334    def test_parent_child_revs(self):
     335        self._hg_init()
     336        self._hg('bookmark', 'initial', '-i')  # root commit
     337        self._create_merge_commit()
     338        self._hg('bookmark', 'latest', '-i')
     339
     340        self._add_repository('hgrepos')
     341        repos = self._repomgr.get_repository('hgrepos')
     342        repos.sync()
     343
     344        rev = repos.normalize_rev('initial')
     345        children = repos.child_revs(rev)
     346        self.assertEqual(2, len(children), 'child_revs: %r' % children)
     347        parents = repos.parent_revs(rev)
     348        self.assertEqual(0, len(parents), 'parent_revs: %r' % parents)
     349        self.assertEqual(1, len(repos.child_revs(children[0])))
     350        self.assertEqual(1, len(repos.child_revs(children[1])))
     351        self.assertEqual([('.hgignore', Node.FILE, Changeset.ADD, None,
     352                           None)],
     353                         sorted(repos.get_changeset(rev).get_changes()))
     354
     355        rev = repos.normalize_rev('latest')
     356        cset = repos.get_changeset(rev)
     357        children = repos.child_revs(rev)
     358        self.assertEqual(0, len(children), 'child_revs: %r' % children)
     359        parents = repos.parent_revs(rev)
     360        self.assertEqual(2, len(parents), 'parent_revs: %r' % parents)
     361        self.assertEqual(1, len(repos.parent_revs(parents[0])))
     362        self.assertEqual(1, len(repos.parent_revs(parents[1])))
     363
     364        # check the differences against the first parent
     365        def fn_repos_changes(entry):
     366            old_node, new_node, kind, change = entry
     367            if old_node:
     368                old_path, old_rev = old_node.path, old_node.rev
     369            else:
     370                old_path, old_rev = None, None
     371            return new_node.path, kind, change, old_path, old_rev
     372        self.assertEqual(sorted(map(fn_repos_changes,
     373                                    repos.get_changes('/', parents[0], '/',
     374                                                      rev))),
     375                         sorted(cset.get_changes()))
     376
     377    _data_annotation1 = """\
     378one
     379two
     380three
     381"""
     382
     383    _data_annotation2 = """\
     384one
     385two
     386three
     387four
     388five
     389six
     390seven
     391eight
     392nine
     393ten
     394"""
     395
     396    _data_annotation3 = """\
     397one
     398two
     3993
     400four
     401five
     4026
     403seven
     404eight
     4059
     406ten
     407"""
     408
     409    def test_get_annotations(self):
     410        self._hg_init(data=False)
     411        filename = 'test.txt'
     412        path = os.path.join(self.repos_path, filename)
     413        create_file(path, self._data_annotation1)
     414        self._hg('add', filename)
     415        self._hg_commit('-m', 'blame')
     416        create_file(path, self._data_annotation2)
     417        self._hg_commit('-m', 'add lines')
     418        create_file(path, self._data_annotation3)
     419        self._hg_commit('-m', 'modify lines')
     420        self._add_repository('hgrepos')
     421        repos = self._repomgr.get_repository('hgrepos')
     422        repos.sync()
     423
     424        rev1 = repos.oldest_rev
     425        rev2 = repos.next_rev(rev1)
     426        rev3 = repos.youngest_rev
     427
     428        self.assertEqual([rev1] * 3,
     429                         repos.get_node('test.txt', rev1).get_annotations())
     430        self.assertEqual([rev1] * 3 + [rev2] * 7,
     431                         repos.get_node('test.txt', rev2).get_annotations())
     432
     433        expected = [rev1, rev1, rev3, rev2, rev2, rev3, rev2, rev2, rev3, rev2]
     434        self.assertEqual(expected,
     435                         repos.get_node('test.txt', rev3).get_annotations())
     436        self.assertEqual(expected,
     437                         repos.get_node('test.txt', 'tip').get_annotations())
     438        self.assertEqual(expected,
     439                         repos.get_node('test.txt').get_annotations())
     440
     441    # *   7 Merge branch 'A'
     442    # |\
     443    # | *   6 Merge branch 'B' into A
     444    # | |\
     445    # | | * 5 Changed a1
     446    # | * | 4 Changed a2
     447    # * | | 3 Changed b2
     448    # | |/
     449    # |/|
     450    # * | 2 Changed b2
     451    # * | 1 Changed b1
     452    # |/
     453    # * 0 First commit
     454
     455    def test_iter_nodes(self):
     456        self._hg_init(data=False)
     457        a1_filename = 'A/a1.txt'
     458        a2_filename = 'A/a2.txt'
     459        b1_filename = 'B/b1.txt'
     460        b2_filename = 'B/b2.txt'
     461        a1_path = os.path.join(self.repos_path, a1_filename)
     462        a2_path = os.path.join(self.repos_path, a2_filename)
     463        b1_path = os.path.join(self.repos_path, b1_filename)
     464        b2_path = os.path.join(self.repos_path, b2_filename)
     465        makedirs(os.path.join(self.repos_path, 'A'))
     466        makedirs(os.path.join(self.repos_path, 'B'))
     467        create_file(a1_path, 'a1')
     468        create_file(a2_path, 'a2')
     469        create_file(b1_path, 'b1')
     470        create_file(b2_path, 'b2')
     471        self._hg('add', a1_filename)
     472        self._hg('add', a2_filename)
     473        self._hg('add', b1_filename)
     474        self._hg('add', b2_filename)
     475        self._hg_commit('-m', 'First commit')
     476        create_file(b1_path, 'b1-1')
     477        self._hg_commit('-m', 'Changed b1')
     478        create_file(b2_path, 'b2-1')
     479        self._hg_commit('-m', 'Changed b2')
     480
     481        create_file(b2_path, 'b2-2')
     482        self._hg_commit('-m', 'Changed b2')
     483        self._hg('update', '0')
     484        create_file(a2_path, 'a2-1')
     485        self._hg_commit('-m', 'Changed a2')
     486        self._hg('update', '2')
     487        create_file(a1_path, 'a1-1')
     488        self._hg_commit('-m', 'Changed 12')
     489
     490        self._hg('merge', '4')
     491        self._hg_commit('-m', "Merge branch 'B' into A")
     492        self._hg('merge', '3')
     493        self._hg_commit('-m', "Merge branch 'A'")
     494
     495        self._add_repository('hgrepos')
     496        repos = self._repomgr.get_repository('hgrepos')
     497        repos.sync()
     498        mod = BrowserModule(self.env)
     499
     500        r0 = repos.normalize_rev('0')
     501        r1 = repos.normalize_rev('1')
     502        r2 = repos.normalize_rev('2')
     503        r3 = repos.normalize_rev('3')
     504        r4 = repos.normalize_rev('4')
     505        r5 = repos.normalize_rev('5')
     506        r6 = repos.normalize_rev('6')
     507        r7 = repos.normalize_rev('7')
     508
     509        root_node = repos.get_node('')
     510        nodes = list(mod._iter_nodes(root_node))
     511        self.assertEqual([r7] * 7,
     512                         [node.rev for node in nodes])
     513        self.assertEqual([
     514            (r7, ''),
     515            (r5, 'A'),
     516            (r5, 'A/a1.txt'),
     517            (r4, 'A/a2.txt'),
     518            (r3, 'B'),
     519            (r1, 'B/b1.txt'),
     520            (r3, 'B/b2.txt'),
     521            ], [(node.created_rev, node.path) for node in nodes])
     522
     523        root_node = repos.get_node('',
     524                                   r6)
     525        nodes = list(mod._iter_nodes(root_node))
     526        self.assertEqual([r6] * 7,
     527                         [node.rev for node in nodes])
     528        self.assertEqual([
     529            (r6, ''),
     530            (r5, 'A'),
     531            (r5, 'A/a1.txt'),
     532            (r4, 'A/a2.txt'),
     533            (r2, 'B'),
     534            (r1, 'B/b1.txt'),
     535            (r2, 'B/b2.txt'),
     536            ], [(node.created_rev, node.path) for node in nodes])
     537
     538        root_commit = r0
     539        root_node = repos.get_node('', root_commit)
     540        nodes = list(mod._iter_nodes(root_node))
     541        self.assertEqual([root_commit] * 7, [node.rev for node in nodes])
     542        self.assertEqual([
     543            (root_commit, ''),
     544            (root_commit, 'A'),
     545            (root_commit, 'A/a1.txt'),
     546            (root_commit, 'A/a2.txt'),
     547            (root_commit, 'B'),
     548            (root_commit, 'B/b1.txt'),
     549            (root_commit, 'B/b2.txt'),
     550            ], [(node.created_rev, node.path) for node in nodes])
     551
     552
     553def test_suite():
     554    suite = unittest.TestSuite()
     555    if HgCommandMixin.hg_bin:
     556        suite.addTest(unittest.makeSuite(SanityCheckingTestCase))
     557        suite.addTest(unittest.makeSuite(HistoryTimeRangeTestCase))
     558        suite.addTest(unittest.makeSuite(HgNormalTestCase))
     559        suite.addTest(unittest.makeSuite(HgRepositoryTestCase))
     560    else:
     561        print("SKIP: tracopt/versioncontrol/hg/tests/hg_fs.py (hg cli "
     562              "binary, 'hg', not found)")
     563    return suite
     564
     565
     566if __name__ == '__main__':
     567    unittest.main(defaultTest='test_suite')