# 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/setup.py
+++ b/setup.py
@@ -97,6 +97,7 @@
         'babel': ['Babel>=0.9.5'],
         'mysql': ['MySQL-python >= 1.2.2'],
         'postgresql': ['psycopg2 >= 2.0'],
+        'hglib': ['python-hglib'],
         'pygments': ['Pygments>=1.0'],
         'rest': ['docutils>=0.3.9'],
         'textile': ['textile>=2.0'],
@@ -148,6 +149,7 @@
         tracopt.ticket.commit_updater = tracopt.ticket.commit_updater
         tracopt.ticket.deleter = tracopt.ticket.deleter
         tracopt.versioncontrol.git.git_fs = tracopt.versioncontrol.git.git_fs
+        tracopt.versioncontrol.hg.hg_fs = tracopt.versioncontrol.hg.hg_fs[hglib]
         tracopt.versioncontrol.svn.svn_fs = tracopt.versioncontrol.svn.svn_fs
         tracopt.versioncontrol.svn.svn_prop = tracopt.versioncontrol.svn.svn_prop
     """,
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
--- /dev/null
+++ b/tracopt/versioncontrol/hg/hg_fs.py
@@ -0,0 +1,393 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2017 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+from datetime import datetime
+import hglib
+import os.path
+import StringIO
+
+from trac.core import *
+from trac.util import TracError
+from trac.util.datefmt import utc
+from trac.util.text import to_unicode, exception_to_unicode
+from trac.util.translation import _
+from trac.versioncontrol.api import Changeset, Node, Repository, \
+                                    IRepositoryConnector, InvalidRepository,\
+                                    NoSuchChangeset, NoSuchNode
+
+
+class HgConnector(Component):
+
+    implements(IRepositoryConnector)
+
+    required = False
+
+    # IRepositoryConnector methods
+
+    def get_supported_types(self):
+        yield ('hg', 8)
+
+    def get_repository(self, type, dir, params):
+        """HgRepository factory method"""
+        assert type == 'hg'
+
+        repos = HgRepository(self.env, dir, params, self.log)
+
+        self.required = True
+        return repos
+
+
+class HgRepository(Repository):
+    """Hg repository"""
+
+    def __init__(self, env, path, params, log):
+        self.env = env
+        self.params = params
+        try:
+            self.hg = hglib.open(path)
+        except hglib.error.ServerError:
+            raise InvalidRepository(
+                _("%(path)s does not appear to be a Mercurial repository.",
+                  path=path))
+
+        Repository.__init__(self, 'hg:' + path, self.params, log)
+
+    def close(self):
+        self.hg.close()
+        self.hg = None
+
+    def get_youngest_rev(self):
+        tip = self.hg.tip()
+        if tip.rev == '-1':
+            return None
+        return tip.node
+
+    def get_path_history(self, path, rev=None, limit=None):
+        raise TracError(_("Unsupported \"Show only adds and deletes\""))
+
+    def get_oldest_rev(self):
+        oldest = self.hg['0']
+        if oldest.rev() == -1:
+            return None
+        return oldest.node()
+
+    def normalize_path(self, path):
+        # manifest and status use inconsistent path sep (on Windows)
+        if path:
+            path = path.replace('\\', '/')
+        return path and path.strip('/') or '/'
+
+    def _changectx(self, rev):
+        try:
+            return self.hg[rev]
+        except ValueError:
+            raise NoSuchChangeset(rev)
+
+    def normalize_rev(self, rev):
+        if not rev:
+            return self.get_youngest_rev()
+        return self._changectx(rev).node()
+
+    def display_rev(self, rev):
+        if not rev:
+            rev = self.youngest_rev
+        try:
+            changectx = self.hg[rev]
+        except ValueError:
+            return None
+        return "%s:%s" % (changectx.rev(), changectx.node()[:12])
+            
+    def short_rev(self, rev):
+        if not rev:
+            rev = self.youngest_rev
+        try:
+            changectx = self.hg[rev]
+        except ValueError:
+            return None
+        return changectx.rev()
+
+    def get_node(self, path, rev=None, changectx=None):
+        return HgNode(self, path, rev, self.log, changectx)
+
+    def get_quickjump_entries(self, rev):
+        for name, rev, node in self.hg.branches():
+            yield 'branches', name, '/', node
+        for name, rev, node in self.hg.bookmarks()[0]:
+            yield 'bookmarks', name, '/', node
+        for name, rev, node, islocal in self.hg.tags():
+            yield 'tags', name, '/', node
+
+    def get_path_url(self, path, rev):
+        return self.params.get('url')
+
+    def get_changesets(self, start, stop):
+        revrange = "reverse(date('>%s') and not date('>%s'))" % (str(start),
+                                                                 str(stop))
+        for entry in self.hg.log(revrange=revrange):
+            yield self.get_changeset(entry.node)
+
+    def get_changeset(self, rev):
+        """HgChangeset factory method"""
+        return HgChangeset(self, rev)
+
+    def get_changeset_uid(self, rev):
+        return self.normalize_rev(rev)
+
+    def get_changes(self, old_path, old_rev, new_path, new_rev,
+                    ignore_ancestry=0):
+        # TODO: handle renames/copies, ignore_ancestry
+        if old_path != new_path:
+            raise TracError(_("Not supported in hg_fs"))
+
+        old_changectx = self._changectx(old_rev)
+        new_changectx = self._changectx(new_rev)
+        revset = '%s:%s' % (old_rev, new_rev)
+        include = None
+        if new_path != '/':
+            include = os.path.join(self.hg.root(), new_path)
+        for action, path in self.hg.status(revset, include=include):
+            change = HgChangeset.action_map[action]
+
+            old_node = None
+            new_node = None
+            if change != Changeset.ADD:
+                old_node = self.get_node(path, old_rev, old_changectx)
+            if change != Changeset.DELETE:
+                new_node = self.get_node(path, new_rev, new_changectx)
+
+            yield old_node, new_node, Node.FILE, change
+
+    def next_rev(self, rev, path=''):
+        revset = 'descendants(children(%s))' % rev
+        if path:
+            fullpath = os.path.join(self.hg.root(), path)
+            entries = self.hg.log(revrange=revset, limit=1,
+                                  files=[fullpath])
+        else:
+            entries = self.hg.log(revrange=revset, limit=1)
+        for entry in entries:
+            return entry.node
+
+    def previous_rev(self, rev, path=''):
+        revset = 'reverse(ancestors(parents(%s)))' % rev
+        if path:
+            fullpath = os.path.join(self.hg.root(), path)
+            entries = self.hg.log(revrange=revset, limit=1,
+                                  files=[fullpath])
+        else:
+            entries = self.hg.log(revrange=revset, limit=1)
+        for entry in entries:
+            return entry.node
+
+    def parent_revs(self, rev):
+        return [changectx.node() for changectx in self.hg[rev].parents()
+                                 if changectx.rev() != -1]
+
+    def child_revs(self, rev):
+        return [changectx.node() for changectx in self.hg[rev].children()]
+
+    def rev_older_than(self, rev1, rev2):
+        return self.short_rev(rev1) < self.short_rev(rev2)
+
+
+class HgNode(Node):
+
+    def __init__(self, repos, path, rev, log, changectx=None):
+        self.log = log
+        self.repos = repos
+        self.fs_size = None
+        self.created_path = path
+
+        if rev:
+            rev = repos.normalize_rev(to_unicode(rev))
+        else:
+            rev = repos.youngest_rev
+
+        self.created_rev = rev
+
+        if changectx is not None:
+            self.changectx = changectx
+        elif rev is None:
+            self.changectx = repos._changectx(-1)
+        else:
+            self.changectx = repos._changectx(rev)
+
+        kind = Node.DIRECTORY
+        p = path.strip('/')
+        self.fullpath = os.path.join(repos.hg.root(), p)
+        if p:  # ie. not the root-tree
+            if not rev:
+                raise NoSuchNode(path, rev)
+
+            if repos.normalize_path(p) in self.changectx.manifest():
+                kind = Node.FILE
+
+            # fix-up to the last commit-rev that touched this node
+            for entry in self.repos.hg.log(revrange='reverse(::%s)' % rev, limit=1,
+                                           files=[self.fullpath]):
+                self.created_rev = entry.node
+                break
+            else:
+                raise NoSuchNode(path, rev)
+
+        Node.__init__(self, repos, path, rev, kind)
+
+    def get_content(self):
+        if not self.isfile:
+            return None
+
+        return StringIO.StringIO(self.repos.hg.cat([self.fullpath],
+                                                   rev=self.rev))
+
+    def get_properties(self):
+        return {}
+
+    def get_annotations(self):
+        if not self.isfile:
+            return
+
+        return [self.repos.normalize_rev(rev) for rev, contents in \
+                self.repos.hg.annotate([self.fullpath], rev=self.rev)]
+
+    def get_entries(self):
+        if not self.rev:
+            return
+        if not self.isdir:
+            return
+
+        parent = self.path.lstrip('/')
+        parent_len = len(parent)
+        if parent != '':
+            parent += '/'
+            parent_len += 1
+
+        entries = set()
+        for path in self.changectx.manifest():
+            if not path:
+                continue
+            if not path.startswith(parent):
+                continue
+
+            pos = path.find('/', parent_len)
+            if pos == -1:
+                entries.add(path)
+            else:
+                entries.add(path[:pos])
+
+        for entry in sorted(entries):
+            yield HgNode(self.repos, entry, self.rev, self.log, self.changectx)
+
+    def get_content_type(self):
+        if self.isdir:
+            return None
+
+        return ''
+
+    def get_content_length(self):
+        if not self.isfile:
+            return None
+
+        if self.fs_size is None:
+            # TODO inefficient?
+            self.fs_size = len(self.repos.hg.cat([self.fullpath],
+                                                 rev=self.rev))
+
+        return self.fs_size
+
+    def get_history(self, limit=None):
+        if not self.rev:
+            return
+        # TODO: follow renames/copies?
+        entries = self.repos.hg.log(revrange='%s:0' % self.rev, limit=limit,
+                                    files=[self.fullpath])
+        if entries:
+            for entry in entries[:-1]:
+                yield (self.path, entry.node, Changeset.EDIT)
+            yield (self.path, entries[-1].node, Changeset.ADD)
+
+    def get_last_modified(self):
+        return self.changectx.date()
+
+
+class HgChangeset(Changeset):
+    """A Mercurial changeset in the Mercurial repository.
+
+    Corresponds to a Mercurial commit.
+    """
+
+    action_map = { # see also "hg status"
+        'M': Changeset.EDIT, # modified
+        'A': Changeset.ADD,
+        'R': Changeset.DELETE, # removed
+    }
+
+    def __init__(self, repos, rev):
+        try:
+            self.changectx = repos.hg[rev]
+        except ValueError:
+            raise NoSuchChangeset(rev)
+
+        msg = self.changectx.description()
+        author = self.changectx.author()
+        date = utc.localize(self.changectx.date())
+        Changeset.__init__(self, repos, rev=rev, message=msg, author=author,
+                           date=date)
+
+    def get_properties(self):
+        properties = {}
+
+        parents = list(c.node() for c in self.changectx.parents()
+                                if changectx.rev() != -1)
+        if parents:
+            properties['Parents'] = parents
+
+        children = list(c.node() for c in self.changectx.children())
+        if children:
+            properties['Children'] = children
+
+        branch = self.changectx.branch()
+        if branch:
+            properties['Branches'] = [branch]
+
+        return properties
+
+    def get_changes(self):
+        edits, adds, dels = self.changectx.status()[:3]
+
+        # TODO find actual base revision of each change
+        base_rev = self.changectx.p1().node()
+        if base_rev == -1:
+            base_rev = None
+
+        for path in edits:
+            base_path = path # TODO find actual base path?
+            yield path, Node.FILE, Changeset.EDIT, base_path, base_rev
+        for path in adds:
+            yield path, Node.FILE, Changeset.ADD, None, None
+        for path in dels:
+            base_path = path # TODO find actual base path?
+            yield path, Node.FILE, Changeset.DELETE, base_path, base_rev
+
+    def get_branches(self):
+        current_branch = self.changectx.branch()
+        current_rev = self.repos.normalize_rev(self.rev)
+        for name, rev, node in self.repos.hg.branches():
+            if current_branch == name:
+                node = self.repos.normalize_rev(node)
+                yield name, node == current_rev
+
+    def get_tags(self):
+        return self.changectx.tags()
+
+    def get_bookmarks(self):
+        return self.changectx.bookmarks()
diff --git a/tracopt/versioncontrol/hg/tests/__init__.py b/tracopt/versioncontrol/hg/tests/__init__.py
new file mode 100644
--- /dev/null
+++ b/tracopt/versioncontrol/hg/tests/__init__.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2017 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+import unittest
+
+from tracopt.versioncontrol.hg.tests import hg_fs
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(hg_fs.test_suite())
+    return suite
+
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
diff --git a/tracopt/versioncontrol/hg/tests/hg_fs.py b/tracopt/versioncontrol/hg/tests/hg_fs.py
new file mode 100644
--- /dev/null
+++ b/tracopt/versioncontrol/hg/tests/hg_fs.py
@@ -0,0 +1,567 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2017 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+import io
+import os
+import sys
+import shutil
+import unittest
+from datetime import datetime, timedelta
+from subprocess import PIPE
+
+from trac.core import TracError
+from trac.test import EnvironmentStub, MockRequest, locate, mkdtemp
+from trac.tests.compat import rmtree
+from trac.util import create_file, makedirs
+from trac.util.compat import Popen, close_fds
+from trac.util.datefmt import to_timestamp, utc
+from trac.util.text import to_utf8
+from trac.versioncontrol.api import Changeset, DbRepositoryProvider, \
+                                    InvalidRepository, Node, \
+                                    NoSuchChangeset, NoSuchNode, \
+                                    RepositoryManager
+from trac.versioncontrol.web_ui.browser import BrowserModule
+from trac.versioncontrol.web_ui.log import LogModule
+from tracopt.versioncontrol.hg.hg_fs import HgRepository
+
+
+class HgCommandMixin(object):
+
+    hg_bin = locate('hg')
+
+    def _hg_commit(self, *args, **kwargs):
+        args2 = ['commit', '--config', 'ui.username=Joe <joe@example.com>']
+        if 'date' in kwargs:
+            args2.append('--date')
+            args2.append(str(kwargs.pop('date')))
+        args2 += args
+        return self._hg(*args2, **kwargs)
+
+    def _spawn_hg(self, *args, **kwargs):
+        args = map(to_utf8, (self.hg_bin,) + args)
+        kwargs.setdefault('stdin', PIPE)
+        kwargs.setdefault('stdout', PIPE)
+        kwargs.setdefault('stderr', PIPE)
+        kwargs.setdefault('cwd', self.repos_path)
+        return Popen(args, close_fds=close_fds, **kwargs)
+
+    def _hg(self, *args, **kwargs):
+        with self._spawn_hg(*args, **kwargs) as proc:
+            stdout, stderr = proc.communicate()
+        self.assertEqual(0, proc.returncode,
+                         'hg exits with %r, args %r, kwargs %r, stdout %r, '
+                         'stderr %r' %
+                         (proc.returncode, args, kwargs, stdout, stderr))
+        return proc
+
+
+class BaseTestCase(unittest.TestCase, HgCommandMixin):
+
+    def setUp(self):
+        self.env = EnvironmentStub()
+        self.repos_path = mkdtemp()
+
+    def tearDown(self):
+        for repos in self._repomgr.get_real_repositories():
+            repos.close()
+        self._repomgr.reload_repositories()
+        self.env.reset_db()
+        if os.path.isdir(self.repos_path):
+            rmtree(self.repos_path)
+
+    @property
+    def _repomgr(self):
+        return RepositoryManager(self.env)
+
+    @property
+    def _dbrepoprov(self):
+        return DbRepositoryProvider(self.env)
+
+    def _add_repository(self, reponame='hgrepos'):
+        path = self.repos_path
+        self._dbrepoprov.add_repository(reponame, path, 'hg')
+
+    def _hg_init(self, data=True):
+        self._hg('init')
+        if data:
+            create_file(os.path.join(self.repos_path, '.hgignore'))
+            self._hg('add', '.hgignore')
+            self._hg_commit('-m', 'test',
+                             date=datetime(2001, 1, 29, 16, 39, 56))
+
+
+class SanityCheckingTestCase(BaseTestCase):
+
+    def test(self):
+        self._hg_init()
+        self._dbrepoprov.add_repository('hgrepos.1', self.repos_path, 'hg')
+        self._repomgr.get_repository('hgrepos.1')
+        self._dbrepoprov.add_repository('hgrepos.2', self.repos_path, 'hg')
+        self._repomgr.get_repository('hgrepos.2')
+
+
+class HistoryTimeRangeTestCase(BaseTestCase):
+
+    def test(self):
+        self._hg_init()
+        filename = os.path.join(self.repos_path, '.hgignore')
+        start = datetime(2000, 1, 1, 0, 0, 0, 0, utc)
+        ts = datetime(2014, 2, 5, 15, 24, 6, 0, utc)
+        for idx in xrange(3):
+            create_file(filename, 'commit-%d.txt' % idx)
+            self._hg_commit('-m', 'commit %d' % idx, date=ts)
+        self._add_repository()
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+
+        revs = [repos.youngest_rev]
+        while True:
+            parents = repos.parent_revs(revs[-1])
+            if not parents:
+                break
+            revs.extend(parents)
+        self.assertEqual(4, len(revs))
+
+        csets = list(repos.get_changesets(start, ts))
+        self.assertEqual(1, len(csets))
+        self.assertEqual(revs[-1], csets[0].rev)  # is oldest rev
+
+        csets = list(repos.get_changesets(start, ts + timedelta(seconds=1)))
+        self.assertEqual(revs, [cset.rev for cset in csets])
+
+
+class HgNormalTestCase(BaseTestCase):
+
+    def test_get_node(self):
+        self._hg_init()
+        self._add_repository()
+        repos = self._repomgr.get_repository('hgrepos')
+        rev = repos.youngest_rev
+        self.assertNotEqual(None, rev)
+        self.assertEqual(40, len(rev))
+
+        self.assertEqual(rev, repos.get_node('/').rev)
+        self.assertEqual(rev, repos.get_node('/', rev[:7]).rev)
+        self.assertEqual(rev, repos.get_node('/.hgignore').rev)
+        self.assertEqual(rev, repos.get_node('/.hgignore', rev[:7]).rev)
+
+        self.assertRaises(NoSuchNode, repos.get_node, '/non-existent')
+        self.assertRaises(NoSuchNode, repos.get_node, '/non-existent', rev[:7])
+        self.assertRaises(NoSuchNode, repos.get_node, '/non-existent', rev)
+        self.assertRaises(NoSuchChangeset,
+                          repos.get_node, '/', 'invalid-revision')
+        self.assertRaises(NoSuchChangeset,
+                          repos.get_node, '/.hgignore', 'invalid-revision')
+        self.assertRaises(NoSuchChangeset,
+                          repos.get_node, '/non-existent', 'invalid-revision')
+
+        self._hg('bookmark', 'abc')
+        repos.sync()
+        self.assertEqual(rev, repos.get_node('/', 'abc').rev)
+        self.assertEqual(rev, repos.get_node('/.hgignore', 'abc').rev)
+
+    def test_on_empty_repos(self):
+        self._hg_init(data=False)
+        self._add_repository()
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+        youngest_rev = repos.youngest_rev
+        self.assertEqual(None, youngest_rev)
+        self.assertEqual(None, repos.oldest_rev)
+        self.assertEqual(None, repos.normalize_rev(''))
+        self.assertEqual(None, repos.normalize_rev(None))
+        self.assertEqual(None, repos.display_rev(''))
+        self.assertEqual(None, repos.display_rev(None))
+        self.assertEqual(None, repos.short_rev(''))
+        self.assertEqual(None, repos.short_rev(None))
+
+        node = repos.get_node('/', youngest_rev)
+        self.assertEqual([], list(node.get_entries()))
+        self.assertEqual([], list(node.get_history()))
+        self.assertRaises(NoSuchNode, repos.get_node, '/path', youngest_rev)
+
+        req = MockRequest(self.env, path_info='/browser/hgrepos')
+        browser_mod = BrowserModule(self.env)
+        self.assertTrue(browser_mod.match_request(req))
+        rv = browser_mod.process_request(req)
+        self.assertEqual('browser.html', rv[0])
+        self.assertEqual(None, rv[1]['rev'])
+
+        req = MockRequest(self.env, path_info='/log/hgrepos')
+        log_mod = LogModule(self.env)
+        self.assertTrue(log_mod.match_request(req))
+        rv = log_mod.process_request(req)
+        self.assertEqual('revisionlog.html', rv[0])
+        self.assertEqual([], rv[1]['items'])
+
+
+class HgRepositoryTestCase(BaseTestCase):
+
+    def _create_merge_commit(self):
+        for idx, branch in enumerate(('alpha', 'beta')):
+            self._hg('update', 'default')
+            self._hg('branch', branch)
+            for n in xrange(2):
+                filename = 'file-%s-%d.txt' % (branch, n)
+                create_file(os.path.join(self.repos_path, filename))
+                self._hg('add', filename)
+                self._hg_commit('-m', filename,
+                                 date=datetime(2014, 2, 2, 17, 12,
+                                               n * 2 + idx))
+        self._hg('update', 'alpha')
+        self._hg('merge', 'beta')
+        self._hg_commit('-m', 'Merge branch "beta" to "alpha"')
+
+    def test_invalid_path_raises(self):
+        self.assertRaises(InvalidRepository, HgRepository, self.env,
+                          '/the/invalid/path', [], self.env.log)
+
+    def test_repository_instance(self):
+        self._hg_init()
+        self._add_repository('hgrepos')
+        self.assertEqual(HgRepository,
+                         type(self._repomgr.get_repository('hgrepos')))
+
+    def test_reset_head(self):
+        self._hg_init()
+        create_file(os.path.join(self.repos_path, 'file.txt'), 'text')
+        self._hg('add', 'file.txt')
+        self._hg_commit('-m', 'test',
+                         date=datetime(2014, 2, 2, 17, 12, 18))
+        self._add_repository('hgrepos')
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+        youngest_rev = repos.youngest_rev
+        entries = list(repos.get_node('').get_history())
+        self.assertEqual(2, len(entries))
+        self.assertEqual('', entries[0][0])
+        self.assertEqual(Changeset.EDIT, entries[0][2])
+        self.assertEqual('', entries[1][0])
+        self.assertEqual(Changeset.ADD, entries[1][2])
+        
+        self._hg('--config', "'extensions.strip='", 'strip', '--rev', '.')
+        repos.sync()
+        new_entries = list(repos.get_node('').get_history())
+        self.assertEqual(1, len(new_entries))
+        self.assertEqual(new_entries[0], entries[1])
+        self.assertNotEqual(youngest_rev, repos.youngest_rev)
+
+    def test_tags(self):
+        self._hg_init()
+        self._add_repository('hgrepos')
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+        self.assertEqual(['default', 'tip'], self._get_quickjump_names(repos))
+        self._hg('tag', 'v1.0') 
+        repos.sync()
+        self.assertEqual(['default', 'tip', 'v1.0'],
+                         self._get_quickjump_names(repos))
+        self._hg('tag', '--remove', 'v1.0')
+        repos.sync()
+        self.assertEqual(['default', 'tip'], self._get_quickjump_names(repos))
+
+    def test_bookmarks(self):
+        self._hg_init()
+        self._add_repository('hgrepos')
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+        self.assertEqual(['default', 'tip'], self._get_quickjump_names(repos))
+        self._hg('bookmarks', 'alpha')
+        repos.sync()
+        self.assertEqual(['default', 'alpha', 'tip'],
+                         self._get_quickjump_names(repos))
+        self._hg('bookmark', '-m', 'alpha', 'beta')
+        repos.sync()
+        self.assertEqual(['default', 'beta', 'tip'],
+                         self._get_quickjump_names(repos))
+        self._hg('bookmark', '-d', 'beta')
+        repos.sync()
+        self.assertEqual(['default', 'tip'], self._get_quickjump_names(repos))
+
+    def _get_quickjump_names(self, repos):
+        return list(name for type, name, path, rev
+                         in repos.get_quickjump_entries('tip'))
+
+    def test_changeset_bookmarks_tags(self):
+        self._hg_init()
+        self._hg('tag', '0.0.1')
+        self._hg('tag', '-m', 'Root commit', 'initial', '-r', '0.0.1')
+        self._hg('bookmark', 'root', '-r', '0.0.1')
+        self._hg('bookmark', 'dev', '-r', '0.0.1')
+        create_file(os.path.join(self.repos_path, 'file.txt'), 'text')
+        self._hg('add', 'file.txt')
+        self._hg_commit('-m', 'Summary')
+        self._hg('bookmark', 'dev', '-i')
+        self._hg('tag', '0.1.0dev')
+        self._hg('tag', '0.1.0a', '-r', '0.1.0dev')
+        self._add_repository('hgrepos')
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+
+        def get_branches(repos, rev):
+            rev = repos.normalize_rev(rev)
+            return list(repos.get_changeset(rev).get_branches())
+
+        def get_bookmarks(repos, rev):
+            rev = repos.normalize_rev(rev)
+            return list(repos.get_changeset(rev).get_bookmarks())
+
+        def get_tags(repos, rev):
+            rev = repos.normalize_rev(rev)
+            return list(repos.get_changeset(rev).get_tags())
+
+        self.assertEqual([('default', False)],
+                         get_branches(repos, '0.0.1'))
+        self.assertEqual([('default', True)],
+                         get_branches(repos, 'tip'))
+        self.assertEqual(['root'],
+                         get_bookmarks(repos, '0.0.1'))
+        self.assertEqual(['dev'], get_bookmarks(repos, '0.1.0dev'))
+        self.assertEqual(['0.0.1', 'initial'], get_tags(repos, '0.0.1'))
+        self.assertEqual(['0.0.1', 'initial'], get_tags(repos, 'initial'))
+        self.assertEqual(['0.1.0a', '0.1.0dev'], get_tags(repos, '0.1.0dev'))
+
+    def test_parent_child_revs(self):
+        self._hg_init()
+        self._hg('bookmark', 'initial', '-i')  # root commit
+        self._create_merge_commit()
+        self._hg('bookmark', 'latest', '-i')
+
+        self._add_repository('hgrepos')
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+
+        rev = repos.normalize_rev('initial')
+        children = repos.child_revs(rev)
+        self.assertEqual(2, len(children), 'child_revs: %r' % children)
+        parents = repos.parent_revs(rev)
+        self.assertEqual(0, len(parents), 'parent_revs: %r' % parents)
+        self.assertEqual(1, len(repos.child_revs(children[0])))
+        self.assertEqual(1, len(repos.child_revs(children[1])))
+        self.assertEqual([('.hgignore', Node.FILE, Changeset.ADD, None,
+                           None)],
+                         sorted(repos.get_changeset(rev).get_changes()))
+
+        rev = repos.normalize_rev('latest')
+        cset = repos.get_changeset(rev)
+        children = repos.child_revs(rev)
+        self.assertEqual(0, len(children), 'child_revs: %r' % children)
+        parents = repos.parent_revs(rev)
+        self.assertEqual(2, len(parents), 'parent_revs: %r' % parents)
+        self.assertEqual(1, len(repos.parent_revs(parents[0])))
+        self.assertEqual(1, len(repos.parent_revs(parents[1])))
+
+        # check the differences against the first parent
+        def fn_repos_changes(entry):
+            old_node, new_node, kind, change = entry
+            if old_node:
+                old_path, old_rev = old_node.path, old_node.rev
+            else:
+                old_path, old_rev = None, None
+            return new_node.path, kind, change, old_path, old_rev
+        self.assertEqual(sorted(map(fn_repos_changes,
+                                    repos.get_changes('/', parents[0], '/',
+                                                      rev))),
+                         sorted(cset.get_changes()))
+
+    _data_annotation1 = """\
+one
+two
+three
+"""
+
+    _data_annotation2 = """\
+one
+two
+three
+four
+five
+six
+seven
+eight
+nine
+ten
+"""
+
+    _data_annotation3 = """\
+one
+two
+3
+four
+five
+6
+seven
+eight
+9
+ten
+"""
+
+    def test_get_annotations(self):
+        self._hg_init(data=False)
+        filename = 'test.txt'
+        path = os.path.join(self.repos_path, filename)
+        create_file(path, self._data_annotation1)
+        self._hg('add', filename)
+        self._hg_commit('-m', 'blame')
+        create_file(path, self._data_annotation2)
+        self._hg_commit('-m', 'add lines')
+        create_file(path, self._data_annotation3)
+        self._hg_commit('-m', 'modify lines')
+        self._add_repository('hgrepos')
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+
+        rev1 = repos.oldest_rev
+        rev2 = repos.next_rev(rev1)
+        rev3 = repos.youngest_rev
+
+        self.assertEqual([rev1] * 3,
+                         repos.get_node('test.txt', rev1).get_annotations())
+        self.assertEqual([rev1] * 3 + [rev2] * 7,
+                         repos.get_node('test.txt', rev2).get_annotations())
+
+        expected = [rev1, rev1, rev3, rev2, rev2, rev3, rev2, rev2, rev3, rev2]
+        self.assertEqual(expected,
+                         repos.get_node('test.txt', rev3).get_annotations())
+        self.assertEqual(expected,
+                         repos.get_node('test.txt', 'tip').get_annotations())
+        self.assertEqual(expected,
+                         repos.get_node('test.txt').get_annotations())
+
+    # *   7 Merge branch 'A'
+    # |\
+    # | *   6 Merge branch 'B' into A
+    # | |\
+    # | | * 5 Changed a1
+    # | * | 4 Changed a2
+    # * | | 3 Changed b2
+    # | |/
+    # |/|
+    # * | 2 Changed b2
+    # * | 1 Changed b1
+    # |/
+    # * 0 First commit
+
+    def test_iter_nodes(self):
+        self._hg_init(data=False)
+        a1_filename = 'A/a1.txt'
+        a2_filename = 'A/a2.txt'
+        b1_filename = 'B/b1.txt'
+        b2_filename = 'B/b2.txt'
+        a1_path = os.path.join(self.repos_path, a1_filename)
+        a2_path = os.path.join(self.repos_path, a2_filename)
+        b1_path = os.path.join(self.repos_path, b1_filename)
+        b2_path = os.path.join(self.repos_path, b2_filename)
+        makedirs(os.path.join(self.repos_path, 'A'))
+        makedirs(os.path.join(self.repos_path, 'B'))
+        create_file(a1_path, 'a1')
+        create_file(a2_path, 'a2')
+        create_file(b1_path, 'b1')
+        create_file(b2_path, 'b2')
+        self._hg('add', a1_filename)
+        self._hg('add', a2_filename)
+        self._hg('add', b1_filename)
+        self._hg('add', b2_filename)
+        self._hg_commit('-m', 'First commit')
+        create_file(b1_path, 'b1-1')
+        self._hg_commit('-m', 'Changed b1')
+        create_file(b2_path, 'b2-1')
+        self._hg_commit('-m', 'Changed b2')
+
+        create_file(b2_path, 'b2-2')
+        self._hg_commit('-m', 'Changed b2')
+        self._hg('update', '0')
+        create_file(a2_path, 'a2-1')
+        self._hg_commit('-m', 'Changed a2')
+        self._hg('update', '2')
+        create_file(a1_path, 'a1-1')
+        self._hg_commit('-m', 'Changed 12')
+
+        self._hg('merge', '4')
+        self._hg_commit('-m', "Merge branch 'B' into A")
+        self._hg('merge', '3')
+        self._hg_commit('-m', "Merge branch 'A'")
+
+        self._add_repository('hgrepos')
+        repos = self._repomgr.get_repository('hgrepos')
+        repos.sync()
+        mod = BrowserModule(self.env)
+
+        r0 = repos.normalize_rev('0')
+        r1 = repos.normalize_rev('1')
+        r2 = repos.normalize_rev('2')
+        r3 = repos.normalize_rev('3')
+        r4 = repos.normalize_rev('4')
+        r5 = repos.normalize_rev('5')
+        r6 = repos.normalize_rev('6')
+        r7 = repos.normalize_rev('7')
+
+        root_node = repos.get_node('')
+        nodes = list(mod._iter_nodes(root_node))
+        self.assertEqual([r7] * 7,
+                         [node.rev for node in nodes])
+        self.assertEqual([
+            (r7, ''),
+            (r5, 'A'),
+            (r5, 'A/a1.txt'),
+            (r4, 'A/a2.txt'),
+            (r3, 'B'),
+            (r1, 'B/b1.txt'),
+            (r3, 'B/b2.txt'),
+            ], [(node.created_rev, node.path) for node in nodes])
+
+        root_node = repos.get_node('',
+                                   r6)
+        nodes = list(mod._iter_nodes(root_node))
+        self.assertEqual([r6] * 7,
+                         [node.rev for node in nodes])
+        self.assertEqual([
+            (r6, ''),
+            (r5, 'A'),
+            (r5, 'A/a1.txt'),
+            (r4, 'A/a2.txt'),
+            (r2, 'B'),
+            (r1, 'B/b1.txt'),
+            (r2, 'B/b2.txt'),
+            ], [(node.created_rev, node.path) for node in nodes])
+
+        root_commit = r0
+        root_node = repos.get_node('', root_commit)
+        nodes = list(mod._iter_nodes(root_node))
+        self.assertEqual([root_commit] * 7, [node.rev for node in nodes])
+        self.assertEqual([
+            (root_commit, ''),
+            (root_commit, 'A'),
+            (root_commit, 'A/a1.txt'),
+            (root_commit, 'A/a2.txt'),
+            (root_commit, 'B'),
+            (root_commit, 'B/b1.txt'),
+            (root_commit, 'B/b2.txt'),
+            ], [(node.created_rev, node.path) for node in nodes])
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    if HgCommandMixin.hg_bin:
+        suite.addTest(unittest.makeSuite(SanityCheckingTestCase))
+        suite.addTest(unittest.makeSuite(HistoryTimeRangeTestCase))
+        suite.addTest(unittest.makeSuite(HgNormalTestCase))
+        suite.addTest(unittest.makeSuite(HgRepositoryTestCase))
+    else:
+        print("SKIP: tracopt/versioncontrol/hg/tests/hg_fs.py (hg cli "
+              "binary, 'hg', not found)")
+    return suite
+
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
