Commits

Steve Borho committed 8004ffd Merge

merge with default (code-freeze for 3.0)

  • Participants
  • Parent commits 271dfac, eab2c63
  • Branches stable

Comments (0)

Files changed (70)

 thgw
 hgext
 mercurial
+.idea/

tests/graph_test.py

     global _tmpdir
     _tmpdir = helpers.mktmpdir(__name__)
 
-    # foo0 -- foo1 ---------- foo3 -------------------------- foo7
-    #   \       \
-    #    \       -------------------- baz4 -- baz5 -- baz6 --------
-    #     \                                        /               \
-    #      ---------- bar2 ------------------------------------------ bar8
-    #       [branch: bar]
-    hg = helpers.HgClient(os.path.join(_tmpdir, 'named-branch'))
-    hg.init()
-    hg.fappend('data', 'foo0')
-    hg.commit('-Am', 'foo0')
-    hg.fappend('data', 'foo1\n')
-    hg.commit('-m', 'foo1')
-    hg.update('0')
-    hg.branch('bar')
-    hg.fappend('data', 'bar2\n')
-    hg.commit('-m', 'bar2')
-    hg.update('1')
-    hg.fappend('data', 'foo3\n')
-    hg.commit('-m', 'foo3')
-    hg.update('1')
-    hg.fappend('data', 'baz4\n')
-    hg.commit('-m', 'baz4')
-    hg.fappend('data', 'baz5\n')
-    hg.commit('-m', 'baz5')
-    hg.merge('--tool=internal:local', '2')
-    hg.commit('-m', 'baz6')
-    hg.update('3')
-    hg.fappend('data', 'foo7\n')
-    hg.commit('-m', 'foo7')
-    hg.update('2')
-    hg.merge('--tool=internal:local', '6')
-    hg.commit('-m', 'bar8')
+    setup_namedbranch()
+    setup_20nodes()
+    setup_20patches()
+    setup_nestedbranch()
+    setup_straightenedbyrevset()
+    setup_bulkgraft()
+    setup_commonedge()
 
+def openrepo(name):
+    return hg.repository(ui.ui(), os.path.join(_tmpdir, name))
+
+# include_mq=True requires thgrepo extension
+def openthgrepo(name):
+    return thgrepo.repository(ui.ui(), os.path.join(_tmpdir, name))
+
+def buildrepo(name, graphtext):
+    path = os.path.join(_tmpdir, name)
+    helpers.buildgraph(path, graphtext)
+
+def buildlinecolortable(grapher):
+    table = {}  # rev: [linecolor, ...]
+    for node in grapher:
+        if not node:
+            continue
+        # draw overlapped lines in the same way as HgRepoListModel
+        ct = dict((p, e.color) for p, e
+                  in sorted(node.bottomlines, key=lambda pe: pe[1].importance))
+        # and sort them in (start, end) order
+        colors = [c for p, c in sorted(ct.iteritems(), key=lambda pc: pc[0])]
+        table[node.rev] = colors
+    return table
+
+def setup_namedbranch():
+    buildrepo('named-branch', r"""
+        8
+        |\  7 [files=data]
+        | 6 | [merge=local]
+        |/| |
+        | 5 | [files=data]
+        | 4 | [files=data]
+        | | 3 [files=data]
+        2 |/ [branch=bar files=data]
+        | 1 [files=data]
+        |/
+        0 [files=data]
+    """)
+
+def test_linecolor_unfiltered():
+    repo = openrepo('named-branch')
+    grapher = graph.revision_grapher(repo, {})
+    c0, c1, c2 = 0, 1, 2
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                              # wt
+        None: [c0],           # |
+                              # 8
+        8: [c0, c1],          # |\
+                              # | | 7
+        7: [c0, c1, c2],      # | | |
+                              # | 6 |
+        6: [c0, c0, c1, c2],  # |/| |
+                              # | 5 |
+        5: [c0, c1, c2],      # | | |
+                              # | 4 |
+        4: [c0, c1, c2],      # | | |
+                              # | | 3
+        3: [c0, c1, c2],      # | |/
+                              # 2 |
+        2: [c0, c1],          # | |
+                              # | 1
+        1: [c0, c1],          # |/
+                              # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def test_linecolor_branchfiltered():
+    repo = openrepo('named-branch')
+    grapher = graph.revision_grapher(repo, {'branch': 'default'})
+    c0, c1 = 0, 1
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                      # 7
+        7: [c0],      # |
+                      # | 6
+        6: [c0, c1],  # | |
+                      # | 5
+        5: [c0, c1],  # | |
+                      # | 4
+        4: [c0, c1],  # | |
+                      # 3 |
+        3: [c0, c1],  # |/
+                      # 1
+        1: [c0],      # |
+                      # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def test_linecolor_filelog():
+    repo = openrepo('named-branch')
+    grapher = graph.filelog_grapher(repo, 'data')
+    c0, c1, c2 = 0, 1, 2
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                          # 7
+        7: [c0],          # |
+                          # |   6
+        6: [c0, c2, c1],  # |  /|
+                          # | | 5
+        5: [c0, c2, c1],  # | | |
+                          # | | 4
+        4: [c0, c2, c1],  # | | |
+                          # 3 | |
+        3: [c0, c2, c1],  #  X /
+                          # 2 |
+        2: [c2, c0],      # | |
+                          # | 1
+        1: [c2, c0],      # |/
+                          # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def setup_20nodes():
     # Graph.index fetches 10 nodes by default
     hg = helpers.HgClient(os.path.join(_tmpdir, '20nodes'))
     hg.init()
         hg.fappend('data', '%d\n' % i)
         hg.commit('-m', str(i))
 
-    hg = helpers.HgClient(os.path.join(_tmpdir, '20patches'))
-    hg.init()
-    hg.fappend('data', '0\n')
-    hg.commit('-Am', '0')
-    hg.fappend('data', '1\n')
-    hg.commit('-Am', '1')
-    for i in xrange(20):
-        hg.qnew('%d.diff' % i)
-    hg.qpop('-a')
-
-def openrepo(name):
-    return hg.repository(ui.ui(), os.path.join(_tmpdir, name))
-
-# include_mq=True requires thgrepo extension
-def openthgrepo(name):
-    return thgrepo.repository(ui.ui(), os.path.join(_tmpdir, name))
-
-def buildlinecolortable(grapher):
-    table = {}  # rev: [linecolor, ...]
-    for node in grapher:
-        if not node:
-            continue
-        colors = [color for start, end, color, _linetype, _children, _rev
-                  in sorted(node.bottomlines)]  # in (start, end) order
-        table[node.rev] = colors
-    return table
-
-def test_linecolor_unfiltered():
-    repo = openrepo('named-branch')
-    grapher = graph.revision_grapher(repo, {})
-    actualtable = buildlinecolortable(grapher)
-    expectedtable = {
-        None: [0],        # |
-        8: [0, 2],        # |\
-        7: [0, 2, 3],     # | | |
-        6: [0, 0, 4, 3],  # |/| |
-        5: [0, 4, 3],     # | | |
-        4: [0, 3, 3],     # | | |
-        3: [0, 3, 3],     # | |/
-        2: [0, 3],        # | |
-        1: [0, 0],        # |/
-        0: [],
-        9: [0],  # TODO bug?
-        }
-    assert_equal(expectedtable, actualtable)
-
-def test_linecolor_branchfiltered():
-    repo = openrepo('named-branch')
-    grapher = graph.revision_grapher(repo, {'branch': 'default'})
-    actualtable = buildlinecolortable(grapher)
-    expectedtable = {
-        7: [1],     # |
-        6: [1, 2],  # | |
-        5: [1, 2],  # | |
-        4: [1, 1],  # | |
-        3: [1, 1],  # |/
-        1: [1],     # |
-        0: [],
-        9: [],  # TODO bug?
-        }
-    assert_equal(expectedtable, actualtable)
-
-def test_linecolor_filelog():
-    repo = openrepo('named-branch')
-    grapher = graph.filelog_grapher(repo, 'data')
-    actualtable = buildlinecolortable(grapher)
-    expectedtable = {
-        7: [0],        # |
-        6: [0, 3, 2],  # | |\
-        5: [0, 3, 2],  # | | |
-        4: [0, 3, 2],  # | | |
-        3: [2, 3, 2],  #  X /
-        2: [3, 2],     # | |
-        1: [3, 3],     # |/
-        0: [],
-        }
-    assert_equal(expectedtable, actualtable)
-
-
 def test_graph_index():
     repo = openrepo('20nodes')
     grapher = graph.revision_grapher(repo, {})
 
     assert_equal(-1, graphobj.index(20))  # unknown
 
+def setup_20patches():
+    hg = helpers.HgClient(os.path.join(_tmpdir, '20patches'))
+    hg.init()
+    hg.fappend('data', '0\n')
+    hg.commit('-Am', '0')
+    hg.fappend('data', '1\n')
+    hg.commit('-Am', '1')
+    for i in xrange(20):
+        hg.qnew('%d.diff' % i)
+    hg.qpop('-a')
+
 def test_graph_index_unapplied_patches():
     repo = openthgrepo('20patches')
     grapher = graph.revision_grapher(repo, {})
     assert_equal(21, graphobj.index(1))
     assert_equal(22, graphobj.index(0))
     assert_equal(23, len(graphobj))
+
+def setup_nestedbranch():
+    buildrepo('nested-branch', r"""
+        9
+        |\
+        | 8 [files=data]
+        | 7
+        6 |\  [files=data]
+        | | 5 [files=data]
+        | 4 | [files=data]
+        | | 3 [files=data]
+        | |/
+        2 | [files=data]
+        | 1 [files=data]
+        |/
+        0 [files=data]
+    """)
+
+def test_linecolor_nestedbranch():
+    repo = openrepo('nested-branch')
+    grapher = graph.revision_grapher(repo, {})
+    c0, c1, c2 = 0, 1, 2
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                              # wt
+        None: [c0],           # |
+                              # 9
+        9: [c0, c1],          # |\
+                              # | 8
+        8: [c0, c1],          # | |
+                              # | 7
+        7: [c0, c1, c2],      # | |\
+                              # 6 | |
+        6: [c0, c1, c2],      # | | |
+                              # | | 5
+        5: [c0, c1, c2],      # | | |
+                              # | 4 |
+        4: [c0, c1, c2],      # | | |
+                              # | | 3
+        3: [c0, c1, c2],      # | |/
+                              # 2 |
+        2: [c0, c1],          # | |
+                              # | 1
+        1: [c0, c1],          # |/
+                              # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def test_linecolor_filelog_nestedbranch():
+    repo = openrepo('nested-branch')
+    grapher = graph.filelog_grapher(repo, 'data')
+    c0, c1, c2 = 0, 1, 2
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                              # 9
+        9: [c0, c1],          # |\
+                              # | 8
+        8: [c0, c1],          # | |
+                              # | 7
+        7: [c0, c1, c2],      # | |\
+                              # 6 | |
+        6: [c0, c1, c2],      # | | |
+                              # | | 5
+        5: [c0, c1, c2],      # | | |
+                              # | 4 |
+        4: [c0, c1, c2],      # | | |
+                              # | | 3
+        3: [c0, c1, c2],      # | |/
+                              # 2 |
+        2: [c0, c1],          # | |
+                              # | 1
+        1: [c0, c1],          # |/
+                              # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def setup_straightenedbyrevset():
+    buildrepo('straightened-by-revset', r"""
+        7
+        6
+        |\
+        5 |
+        | 4
+        3 |
+        | 2
+        |/
+        1
+        0
+    """)
+
+def test_linecolor_straightened_by_revset():
+    repo = openrepo('straightened-by-revset')
+    revset = set([0, 1, 2, 4, 6, 7]) # exclude 3, 5
+    grapher = graph.revision_grapher(repo, {"revset": revset})
+    c0, c1 = 0, 1
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                              # 7
+        7: [c0],              # |
+                              # 6
+        6: [c1],              #  \
+                              #   4
+        4: [c1],              #   |
+                              #   2
+        2: [c1],              #  /
+                              # 1
+        1: [c0],              # |
+                              # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def test_linecolor_straightened_by_revset_2():
+    repo = openrepo('straightened-by-revset')
+    revset = set([0, 1, 2, 3, 4, 6, 7]) # exclude 5
+    grapher = graph.revision_grapher(repo, {"revset": revset})
+    c0, c1 = 0, 1
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                              # 7
+        7: [c0],              # |
+                              # 6
+        6: [c1],              # |
+                              # 4
+        4: [c1],              # |
+                              # | 3
+        3: [c1, c0],          # | |
+                              # 2 |
+        2: [c1, c0],          # |/
+                              # 1
+        1: [c0],              # |
+                              # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def test_linecolor_straightened_by_revset_3():
+    repo = openrepo('straightened-by-revset')
+    revset = set([0, 1, 2, 3, 6, 7]) # exclude 4, 5
+    grapher = graph.revision_grapher(repo, {"revset": revset})
+    c0, c1 = 0, 1
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                              # 7
+        7: [c0],              # |
+                              # 6
+        6: [],                #
+                              # 3
+        3: [c0],              # |
+                              # | 2
+        2: [c0, c1],          # |/
+                              # 1
+        1: [c0],              # |
+                              # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def test_linecolor_straightened_by_revset_4():
+    repo = openrepo('straightened-by-revset')
+    revset = set([0, 1, 3, 4, 6, 7]) # exclude 2, 5
+    grapher = graph.revision_grapher(repo, {"revset": revset})
+    c0, c1 = 0, 1
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                              # 7
+        7: [c0],              # |
+                              # 6
+        6: [c1],              #  \
+                              #   4
+        4: [],                #
+                              # 3
+        3: [c0],              # |
+                              # 1
+        1: [c0],              # |
+                              # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)
+
+def setup_bulkgraft():
+    buildrepo('bulkgraft', r"""
+    7 [source=3]
+    6 [source=2]
+    5 [source=1]
+    4
+    | 3
+    | 2
+    | 1
+    |/
+    0
+    """)
+
+def test_linecolor_bulkgraft():
+    repo = openrepo('bulkgraft')
+    grapher = graph.revision_grapher(repo, {"showgraftsource": True})
+    c0, c1, c2, c3, c4 = 0, 1, 2, 3, 4
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {
+                                # wt
+        None: [c0],             # |
+                                # 7
+        7: [c0, c1],            # |\
+                                # 6 .
+        6: [c0, c2, c1],        # |\ \
+                                # 5 . .
+        5: [c0, c3, c2, c1],    # |\ \ \
+                                # 4 : : :
+        4: [c0, c3, c2, c1],    # | : : :
+                                # | : : 3
+        3: [c0, c3, c2, c4],    # | : :/
+                                # | : 2
+        2: [c0, c3, c4],        # | :/
+                                # | 1
+        1: [c0, c4],            # |/
+                                # 0
+        0: [],
+    }
+    assert_equal(expectedtable, actualtable)
+
+def setup_commonedge():
+    buildrepo('commonedge', r"""
+        6
+        | 5
+        |/
+        | 4
+        |/
+        | 3
+        | |
+        | 2
+        |/
+        1
+        |
+        0
+    """)
+
+def test_linecolor_commonedge():
+    repo = openrepo('commonedge')
+    grapher = graph.revision_grapher(repo, {})
+    c0, c1, c2, c3 = 0, 1, 2, 3
+    actualtable = buildlinecolortable(grapher)
+    expectedtable = {       # wt
+        None: [c0],         # |
+                            # 6
+        6: [c0],            # |
+                            # | 5
+        5: [c0, c1],        # |/
+                            # | 4
+        4: [c0, c2],        # |/
+                            # | 3
+        3: [c0, c3],        # | |
+                            # | 2
+        2: [c0, c3],        # |/
+                            # 1
+        1: [c0],            # |
+                            # 0
+        0: [],
+        }
+    assert_equal(expectedtable, actualtable)

tests/graphbuilder_test.py

+import tempfile
+from nose.tools import *
+
+from mercurial.node import nullrev as X
+from mercurial import hg, ui
+
+import helpers
+
+def setup():
+    global _tmpdir
+    _tmpdir = helpers.mktmpdir(__name__)
+
+
+def createrepo(textgraph):
+    path = tempfile.mktemp(dir=_tmpdir)
+    helpers.buildgraph(path, textgraph)
+    return hg.repository(ui.ui(), path)
+
+def test_edge_connection_1():
+    repo = createrepo(r"""
+      o
+     /
+    o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_edge_connection_2():
+    repo = createrepo(r"""
+    o
+     \
+      o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_edge_connection_3():
+    repo = createrepo(r"""
+    o
+     \
+     /
+    o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_edge_connection_4():
+    repo = createrepo(r"""
+    o
+     \
+      |
+      o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_edge_connection_5():
+    repo = createrepo(r"""
+    o
+     \
+      \
+       o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_edge_connection_6():
+    repo = createrepo(r"""
+      o
+      |
+     /
+    o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_edge_connection_7():
+    repo = createrepo(r"""
+       o
+      /
+     /
+    o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_edge_connection_8():
+    repo = createrepo(r"""
+      o
+     /
+    |
+    o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_edge_connection_9():
+    repo = createrepo(r"""
+      o
+     /
+     \
+      o
+    """)
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_straight():
+    repo = createrepo(r"""
+    o
+    |
+    o
+    o # lines which have only "|" can be omitted
+    o
+    """)
+    assert_equal((2, X), repo.changelog.parentrevs(3))
+    assert_equal((1, X), repo.changelog.parentrevs(2))
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_branched():
+    repo = createrepo(r"""
+    o
+    |  o
+    o /
+    |/
+    o
+    """)
+    assert_equal((1, X), repo.changelog.parentrevs(3))
+    assert_equal((0, X), repo.changelog.parentrevs(2))
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_merged():
+    repo = createrepo(r"""
+    4
+    |\
+    | 3
+    |/|
+    | 2 [branch=foo]
+    1 |
+    |/
+    0
+    """)
+    assert_equal((1, 3), repo.changelog.parentrevs(4))
+    assert_equal((2, 1), repo.changelog.parentrevs(3))
+    assert_equal((0, X), repo.changelog.parentrevs(2))
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_merged_2():
+    repo = createrepo(r"""
+    3
+    2\
+    | 1
+    |/
+    0
+    """)
+    assert_equal((2, 1), repo.changelog.parentrevs(3))
+
+def test_horizontaledge_1():
+    repo = createrepo(r"""
+    6
+    | 5
+    +---4
+    3 | |
+    |\|/
+    | 2 [branch=foo]
+    1 |
+    |/
+    0
+    """)
+    assert_equal((3, X), repo.changelog.parentrevs(6))
+    assert_equal((2, X), repo.changelog.parentrevs(5))
+    assert_equal((2, 3), repo.changelog.parentrevs(4))
+    assert_equal((1, 2), repo.changelog.parentrevs(3))
+    assert_equal((0, X), repo.changelog.parentrevs(2))
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_horizontaledge_2():
+    repo = createrepo(r"""
+    7
+    |   6
+    | 5---+     # right to left
+    | | | |
+    | |/  4
+    | 3  /
+    +---2       # left to right
+    |/
+    1
+    0
+    """)
+    assert_equal((1, X), repo.changelog.parentrevs(7))
+    assert_equal((3, X), repo.changelog.parentrevs(6))
+    assert_equal((3, 4), repo.changelog.parentrevs(5))
+    assert_equal((2, X), repo.changelog.parentrevs(4))
+    assert_equal((1, X), repo.changelog.parentrevs(3))
+    assert_equal((1, X), repo.changelog.parentrevs(2))
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_horizontaledge_double():
+    repo = createrepo(r"""
+    4
+    | +-3-+ [branch=foo]
+    | |   2 [branch=foo]
+    | |  /
+    | | /
+    |/ /
+    1 /
+    |/
+    0
+    """)
+    assert_equal((1, X), repo.changelog.parentrevs(4))
+    assert_equal((2, 1), repo.changelog.parentrevs(3))
+    assert_equal((0, X), repo.changelog.parentrevs(2))
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+
+def test_p1_selection_bybranch_1():
+    """treat parent which has same branch as p1"""
+    repo = createrepo(r"""
+    3 [branch=foo]
+    |\
+    2 |
+    | 1 [branch=foo]
+    |/
+    0
+    """)
+    assert_equal((1, 2), repo.changelog.parentrevs(3))
+
+def test_p1_selection_bybranch_2():
+    """treat parent which has same branch as p1"""
+    repo = createrepo(r"""
+      3 [branch=default]
+     /|
+    2 |
+    | 1 [branch=foo]
+    |/
+    0
+    """)
+    assert_equal((2, 1), repo.changelog.parentrevs(3))
+
+def test_p1_selection_bybranch_3():
+    """treat parent which has same branch as p1"""
+    repo = createrepo(r"""
+    +-3--+ [branch=default]
+    |   /
+    2  /
+    | 1 [branch=foo]
+    |/
+    0
+    """)
+    assert_equal((2, 1), repo.changelog.parentrevs(3))
+
+def test_p1_selection_bybranch_4():
+    """treat parent which has same branch as p1"""
+    repo = createrepo(r"""
+    +-3--+ [branch=foo]
+    |   /
+    2  /
+    | 1 [branch=foo]
+    |/
+    0
+    """)
+    assert_equal((1, 2), repo.changelog.parentrevs(3))
+
+def test_p1_selection_bygraph_1():
+    """treat parent under '|' as p1 when can't determine by branch"""
+    repo = createrepo(r"""
+    3
+    |\
+    2 |
+    | 1
+    |/
+    0
+    """)
+    assert_equal((2, 1), repo.changelog.parentrevs(3))
+
+def test_p1_selection_bygraph_2():
+    """treat parent under '|' as p1 when can't determine by branch"""
+    repo = createrepo(r"""
+      3
+     /|
+    2 |
+    | 1
+    |/
+    0
+    """)
+    assert_equal((1, 2), repo.changelog.parentrevs(3))
+
+def test_p1_selection_bygraph_3():
+    """treat parent under '|' as p1 when can't determine by branch"""
+    repo = createrepo(r"""
+    3 [branch=default]
+    |\
+    2 |
+    | 1
+    |/
+    0
+    """)
+    assert_equal((2, 1), repo.changelog.parentrevs(3))
+
+def test_p1_selection_bygraph_4():
+    """treat parent under '|' as p1 when can't determine by branch"""
+    repo = createrepo(r"""
+      3 [branch=default]
+     /|
+    2 |
+    | 1
+    |/
+    0
+    """)
+    assert_equal((1, 2), repo.changelog.parentrevs(3))
+
+def test_p1_selection_bygraph_5():
+    """treat parent under '|' as p1 when can't determine by branch"""
+    repo = createrepo(r"""
+    3
+    |\
+    2 |
+    | 1 [branch=foo]
+    |/
+    0
+    """)
+    assert_equal((2, 1), repo.changelog.parentrevs(3))
+    assert_equal('default', repo[3].branch())
+
+def test_p1_selection_bygraph_6():
+    """treat parent under '|' as p1 when can't determine by branch"""
+    repo = createrepo(r"""
+      3
+     /|
+    2 |
+    | 1 [branch=foo]
+    |/
+    0
+    """)
+    assert_equal((1, 2), repo.changelog.parentrevs(3))
+    assert_equal('foo', repo[3].branch())
+
+def test_cross_edge():
+    repo = createrepo(r"""
+    9
+    |           8
+    |     7     |
+    |   6 |     |
+    | 5 |  \   /
+    +-------------4
+    |/   \  | |
+    +-----3 | |
+    +-------2 |
+    +---------1
+    0
+    """)
+    assert_equal((1, X), repo.changelog.parentrevs(8))
+    assert_equal((2, X), repo.changelog.parentrevs(7))
+    assert_equal((3, X), repo.changelog.parentrevs(6))
+    assert_equal((0, X), repo.changelog.parentrevs(5))
+
+def test_comment():
+    repo = createrepo(r"""
+    o
+    |#o
+    |
+    o
+    """)
+    assert_equal(2, len(repo))
+
+def test_branch():
+    repo = createrepo(r"""
+      4
+     /|
+    3 |
+    |\|
+    | 2 [branch=foo]
+    1 |
+    |/
+    0
+    """)
+    assert_equal("default", repo[0].branch())
+    assert_equal("default", repo[1].branch())
+    assert_equal("foo", repo[2].branch())
+    assert_equal("default", repo[3].branch())
+    assert_equal("foo", repo[4].branch())
+
+def test_user():
+    repo = createrepo(r"""
+    1 [user=bob]
+    |
+    0
+    """)
+    assert_equal("alice", repo[0].user())
+    assert_equal("bob", repo[1].user())
+
+def test_files():
+    repo = createrepo(r"""
+    1 [files="foo,bar/baz"]
+    |
+    0
+    """)
+
+    assert_equal(set([helpers.GraphBuilder.DUMMYFILE, "foo", "bar/baz"]),
+                 set(repo[1].files()))
+
+def _contentgetter(repo, path):
+    return lambda rev: repo[rev].filectx(path).data()
+
+def test_merge():
+    repo = createrepo(r"""
+    3
+    |\
+    | 2 [branch=x files=foo]
+    1 | [files=foo]
+    |/
+    0
+    """)
+    foo = _contentgetter(repo, "foo")
+    ok_(foo(1) != foo(2))
+    ok_(foo(1) != foo(3))
+    ok_(foo(2) != foo(3))
+    ok_(foo(3).find("<<<") < 0)
+
+def test_merge_local():
+    repo = createrepo(r"""
+    3 [merge=local]
+    |\
+    | 2 [files='foo, bar']
+    1 | [files=foo]
+    |/
+    0
+    """)
+    foo = _contentgetter(repo, "foo")
+    bar = _contentgetter(repo, "bar")
+    ok_(foo(1) != foo(2))
+    ok_(foo(1) == foo(3))
+    ok_(bar(2) == bar(3))
+
+def test_merge_other():
+    repo = createrepo(r"""
+    3 [merge=other]
+    |\
+    | 2 [files=foo]
+    1 | [files='foo, bar']
+    |/
+    0
+    """)
+    foo = _contentgetter(repo, "foo")
+    bar = _contentgetter(repo, "bar")
+    ok_(foo(1) != foo(2))
+    ok_(foo(2) == foo(3))
+    ok_(bar(1) == bar(3))
+
+
+def _sourcerev(repo, rev):
+    source = repo[rev].extra()['source']
+    return repo[source].rev()
+
+def test_graft():
+    repo = createrepo(r"""
+
+    3   [source=2]
+    | 2
+    1 |
+    |/
+    0
+    """)
+    assert_equal((1, X), repo.changelog.parentrevs(3))
+    assert_equal((0, X), repo.changelog.parentrevs(2))
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+    assert_equal(2, _sourcerev(repo, 3))
+
+def test_graft_branch():
+    repo = createrepo(r"""
+      4 [branch=default]
+     /|
+    3 | [source=2]
+    | 2 [branch=foo]
+    1 |
+    |/
+    0
+    """)
+    assert_equal((3, 2), repo.changelog.parentrevs(4))
+    assert_equal((1, X), repo.changelog.parentrevs(3))
+    assert_equal((0, X), repo.changelog.parentrevs(2))
+    assert_equal((0, X), repo.changelog.parentrevs(1))
+    assert_equal(2, _sourcerev(repo, 3))
+
+def test_graft_local():
+    repo = createrepo(r"""
+    3   [source=2 merge=local]
+    | 2 [files=foo,bar]
+    1 | [files=foo]
+    |/
+    0 [files=foo]
+    """)
+    foo = _contentgetter(repo, "foo")
+    ok_(foo(1) == foo(3))
+    ok_(foo(2) != foo(3))
+
+def test_graft_other():
+    repo = createrepo(r"""
+    3   [source=2 merge=other]
+    | 2 [files=foo]
+    1 | [files=foo]
+    |/
+    0 [files=foo]
+    """)
+    foo = _contentgetter(repo, "foo")
+    ok_(foo(1) != foo(3))
+    ok_(foo(2) == foo(3))
+
+def test_graft_user():
+    repo = createrepo(r"""
+    3   [source=2 user=bob]
+    | 2
+    1 |
+    |/
+    0
+    """)
+    assert_equal("bob", repo[3].user())
+
+def shouldraiseerror(graph, expected):
+    # we can't use `with assert_raises()` because `with` is not supported
+    # by Python 2.4
+    try:
+        createrepo(graph)
+        ok_(False, "InvalidGraph should be raised")
+    except helpers.InvalidGraph, ex:
+        assert_equal(expected, ex.innermessage)
+
+def test_error_multirev():
+    shouldraiseerror(r"""
+    o o
+    |/
+    o
+    """, "2 or more rev in same line")
+
+def test_error_isolatededge():
+    shouldraiseerror(r"""
+    o
+    |\
+    |
+    o
+    """, "isolated edge")
+
+def test_error_isolatededge_2():
+    shouldraiseerror(r"""
+    o
+    |
+    |/
+    o
+    """, "isolated edge")
+
+def test_error_toomanyparents():
+    shouldraiseerror(r"""
+      o
+     /|
+    o |\
+    |/  |
+    +---o
+    o
+    """, "too many parents")
+
+def test_error_toomanyparents_2():
+    shouldraiseerror(r"""
+      o-+
+     /| |
+    o | |
+    |/  |
+    +---o
+    o
+    """, "too many parents")
+
+def test_error_invalidhorizontaledge():
+    shouldraiseerror(r"""
+    o
+    +-+o+
+    |/ /
+    | /
+    |/
+    o
+    """, "invalid horizontal edge")
+
+def test_error_invalidhorizontaledge_2():
+    shouldraiseerror(r"""
+    o
+    + o
+    |/
+    o
+    """, "invalid horizontal edge")
+
+def test_error_invalidsource_1():
+    shouldraiseerror(r"""
+    1 [source=1a]
+    |
+    0
+    """, "`source` must be integer")
+
+def test_error_invalidsource_2():
+    shouldraiseerror(r"""
+    1 [source=2]
+    |
+    0
+    """, "`source` must point past revision")
+
+def test_error_graftwith2parents():
+    shouldraiseerror(r"""
+    2 [source=1]
+    |\
+    | 1
+    |/
+    0
+    """, "grafted revision must have only one parent")
 """Helper functions or classes imported from test case"""
-import os, tempfile
+import os, re, sys, tempfile
+from collections import defaultdict
 try:
     import cStringIO as StringIO
 except ImportError:
     import StringIO
 from nose import tools
 from PyQt4.QtCore import QTextCodec
-from mercurial import dispatch, encoding as encodingmod, ui as uimod
+from mercurial import dispatch, encoding as encodingmod, ui as uimod, util
 from tortoisehg.util import hglib
 
 def mktmpdir(prefix):
     def ftouch(self, *paths):
         """Create empty file inside the repository"""
         for e in paths:
-            fullpath = self.wjoin(e)
-            if not os.path.exists(os.path.dirname(fullpath)):
-                os.makedirs(os.path.dirname(fullpath))
-            open(fullpath, 'w').close()
+            self.fwrite(e, '')
 
-    def fwrite(self, path, content):
-        """Write the given content to file"""
-        f = open(self.wjoin(path), 'wb')
+    def _fwrite(self, path, content, flag):
+        fullpath = self.wjoin(path)
+        if not os.path.exists(os.path.dirname(fullpath)):
+            os.makedirs(os.path.dirname(fullpath))
+        f = open(fullpath, flag)
         try:
             f.write(content)
         finally:
             f.close()
 
+    def fwrite(self, path, content):
+        """Write the given content to file"""
+        self._fwrite(path, content, 'wb')
+
     def fappend(self, path, content):
         """Append the given content to file"""
-        f = open(self.wjoin(path), 'ab')
-        try:
-            f.write(content)
-        finally:
-            f.close()
+        self._fwrite(path, content, 'ab')
 
     def fread(self, path):
         """Read content of file"""
         if path.startswith('/'):
             raise ValueError('not a relative path: %s' % path)
         return os.path.join(self.path, path)
+
+
+class InvalidGraph(Exception):
+    def __init__(self, lines, lineno, msg):
+        graph = []
+        for i, line in enumerate(lines):
+            if i == lineno:
+                graph.append(line + ' << [NG]')
+            else:
+                graph.append(line)
+        graphtext = '\n'.join(reversed(graph))
+        Exception.__init__(self, 'Invalid graph:\n' + graphtext + '\n' + msg)
+        self.lines = list(reversed(lines))
+        self.errorline = len(lines) - 1 - lineno
+        self.innermessage = msg
+
+
+class InvalidGraphLine(Exception):
+    pass
+
+
+class GraphBuilder(object):
+    _re_graphline = re.compile(r"""^
+                (?P<graph>[ o0-9|/\\+\-]*)       #graph
+                (\s*\[(?P<params>[^#]+)\]\s*)?   #params
+                (\#(?P<comment>.*))?             #comment
+                $""", re.VERBOSE)
+
+    _re_branchparams = re.compile(r"""
+                ([0-9a-z_/:\-]+)                    # key
+                \s*=\s*                             # =
+                (?:"([^"]*)"|'([^']*)'|([^\s='"]*)) # value
+                """, re.I | re.VERBOSE)
+
+    DUMMYFILE = '.dummy'
+
+    def __init__(self, path, textgraph):
+        textgraph = textgraph.strip('\n')
+        self.lines = [l.rstrip() for l in reversed(textgraph.split('\n'))]
+        self.hg = HgClient(path)
+        self.hg.init()
+        self.hg.fappend('.hgignore', 'syntax: glob\n*.orig\n')
+        self._branchmap = {}
+
+    def build(self):
+        revs = defaultdict(list)
+        prevline = ''
+        tip = -1
+        for i, line in enumerate(self.lines):
+            try:
+                m = self._re_graphline.match(line)
+                if not m:
+                    raise InvalidGraphLine('parse error')
+                line = m.group('graph')
+                if not line:
+                    continue
+                params = m.group('params')
+                revs, tip = self._processline(line, params, prevline, revs, tip)
+                prevline = line
+            except InvalidGraphLine, ex:
+                raise InvalidGraph(self.lines, i, str(ex)), \
+                        None, sys.exc_info()[2]
+            except util.Abort, ex:
+                raise InvalidGraph(self.lines, i, "[Abort] %s" % ex), \
+                        None, sys.exc_info()[2]
+
+    @classmethod
+    def _parseparams(cls, text):
+        """
+        parse branch parameters.
+        return dict which may contain 'branch', 'user', and 'files'
+
+        >>> _parseparams = GraphBuilder._parseparams
+        >>> _parseparams('')
+        {}
+        >>> # branch
+        >>> _parseparams('branch=foo')
+        {'branch': 'foo'}
+        >>> _parseparams('user=alice')
+        {'user': 'alice'}
+        >>> _parseparams('merge=local')
+        {'merge': 'local'}
+        >>> _parseparams('source=0')
+        {'source': '0'}
+        >>> _parseparams('files=foo/bar')
+        {'files': ['foo/bar']}
+        >>> _parseparams('files="foo,bar, baz"')
+        {'files': ['foo', 'bar', 'baz']}
+        >>> ret = _parseparams('user=alice branch=foo files=bar')
+        >>> sorted(ret.items())
+        [('branch', 'foo'), ('files', ['bar']), ('user', 'alice')]
+        """
+        ret = {}
+        if text:
+            for k, v1, v2, v3 in cls._re_branchparams.findall(text):
+                v = v1 or v2 or v3
+                if k == 'files':
+                    ret["files"] = re.split(r',\s*', v)
+                elif k in ('branch', 'user', 'merge', 'source'):
+                    ret[k] = v
+                else:
+                    raise InvalidGraphLine('undefined param: %s' % k)
+        return ret
+
+    def _findhorizontaledgeroot(self, line, edgeend):
+        """
+        search index of horizontal edge root from revmark
+          | +---o   horizontal edge root means '+' of left fig.
+          | | |     revmark means 'o' of left fig.
+        """
+        roots = [i for (i, c) in enumerate(line) if c == '+']
+        if len(roots) > 0:
+            if len(roots) > 2:
+                raise InvalidGraphLine('invalid horizontal edge')
+
+            indices = roots + [edgeend]
+            l, r = min(indices), max(indices)
+            if util.any((l < i < r) != (c == '-') for (i, c) in enumerate(line)
+                        if i not in indices):
+                raise InvalidGraphLine('invalid horizontal edge')
+
+        return roots
+
+    def _processline(self, line, params, prevline, revs, tip):
+        """process one line"""
+        next_revs = defaultdict(list)
+        committed = False
+
+        iscommit = lambda c: c in 'o0123456789'
+
+        visitededges = set()
+        for i, c in sorted(enumerate(line), key=lambda x: iscommit(x[1])):
+            parents = []
+            if c in '|+-' or iscommit(c):
+                parents += revs[i]
+                visitededges.add(i)
+                if 0 < i and i - 1 < len(prevline) and prevline[i - 1] == '/':
+                    parents += revs[i - 1]
+                    visitededges.add(i - 1)
+                if i < len(prevline) - 1 and prevline[i + 1] == '\\':
+                    parents += revs[i + 1]
+                    visitededges.add(i + 1)
+
+                if iscommit(c):
+                    if committed:
+                        raise InvalidGraphLine('2 or more rev in same line')
+                    for root in self._findhorizontaledgeroot(line, i):
+                        parents += next_revs[root]
+                        visitededges.add(root)
+                    if len(parents) > 2:
+                        raise InvalidGraphLine('too many parents')
+
+                    params = GraphBuilder._parseparams(params)
+                    if params.get('source') is not None:
+                        tip = self._graft(tip, parents, **params)
+                    else:
+                        tip = self._commit(tip, parents, **params)
+                    parents = [tip]
+                    committed = True
+
+            elif c == '/':
+                parents += revs[i - 1]
+                visitededges.add(i - 1)
+                if i < len(prevline) and prevline[i] == '\\':
+                    parents += revs[i]
+                    visitededges.add(i)
+
+            elif c == '\\':
+                parents += revs[i + 1]
+                visitededges.add(i + 1)
+                if i < len(prevline) and prevline[i] == '/':
+                    parents += revs[i]
+                    visitededges.add(i)
+
+            else:
+                continue
+
+            if not parents and c != '-' and tip >= 0:
+                raise InvalidGraphLine('isolated edge')
+
+            next_revs[i] = parents
+
+        if (set(i for (i, c) in enumerate(prevline) if c in r'|\/')
+            - visitededges):
+            raise InvalidGraphLine('isolated edge')
+
+        return next_revs, tip
+
+
+    def _sortparents(self, parents, rev, branch):
+        """move same branch parent first if branch specified explicitly"""
+        bm = self._branchmap
+        if branch:
+            bm[rev] = branch
+            parents.sort(key=lambda p: bm[p] != branch)
+        else:
+            if parents:
+                bm[rev] = self._branchmap[parents[0]]
+            else:
+                bm[rev] = 'default'
+
+    def _runcmd(self, cmd, *args):
+            ret, fout, ferr = cmd(*args)
+            if ret != 0:
+                raise InvalidGraphLine(
+                        '\n'.join(['failed to %s' % cmd.func_name, fout, ferr]))
+
+    def _resolveall(self, p1, p2):
+        unresolved = [x[2:] for x in self.hg.resolve('-l')[1].split('\n')
+                      if x.startswith('U ')]
+        for path in unresolved:
+            self.hg.fwrite(path, '@%d+@%d' % (p1, p2))
+        self._runcmd(self.hg.resolve, '-ma')
+
+    def _commit(self, tip, parents, branch=None, user=None, files=None,
+                merge=None, source=None):
+        assert(source is None)
+        if merge:
+            if len(parents) < 2:
+                raise InvalidGraphLine('`merge` can be specified to'
+                                       ' merged or grafted revision')
+            if merge not in ('local', 'other'):
+                raise InvalidGraphLine(
+                        'value of `merge` must be "local" or "other"')
+        hg = self.hg
+
+        nextrev = tip + 1
+        self._sortparents(parents, nextrev, branch)
+
+        if parents and tip != parents[0]:
+            self._runcmd(hg.update, '-Cr', str(parents[0]))
+        if branch:
+            self._runcmd(hg.branch, branch, '-f')
+        if len(parents) > 1:
+            tool = 'internal:' + (merge or 'merge')
+            ret = hg.merge('-r', str(parents[1]), '-t', tool)[0]
+            if ret:
+                self._resolveall(*parents)
+        else:
+            # change DUMMYFILE every commit
+            hg.fwrite(self.DUMMYFILE, '@%d' % nextrev)
+        if files:
+            for path in files:
+                if path == self.DUMMYFILE:
+                    raise InvalidGraphLine(
+                            'file:%s is used internally' % self.DUMMYFILE)
+                hg.fwrite(path, '@%d' % nextrev)
+
+        user = user or 'alice'
+        self._runcmd(hg.commit, '-Am', 'commit #%d' % nextrev, '-u', user)
+        return nextrev
+
+    def _graft(self, tip, parents, branch=None, user=None, files=None,
+               merge=None, source=None):
+        assert(source is not None)
+        if merge and merge not in ('local', 'other'):
+            raise InvalidGraphLine(
+                    'value of `merge` must be "local" or "other"')
+        try:
+            if not (0 <= int(source) <= tip):
+                raise InvalidGraphLine('`source` must point past revision')
+        except ValueError:
+            raise InvalidGraphLine('`source` must be integer')
+        if files or branch:
+            raise InvalidGraphLine('`files` and `branch` cannot be specified'
+                                   ' with `source`')
+        if len(parents) != 1:
+            raise InvalidGraphLine('grafted revision must have only one parent')
+
+        hg = self.hg
+
+        nextrev = tip + 1
+        self._branchmap[nextrev] = self._branchmap[parents[0]]
+
+        if tip != parents[0]:
+            self._runcmd(hg.update, '-Cr', str(parents[0]))
+        tool = 'internal:' + (merge or 'merge')
+        if user:
+            uargs = ['-u', user]
+        else:
+            uargs = []
+        try:
+            hg.graft('-r', source, '-t', tool, *uargs)
+        except util.Abort:
+            self._resolveall(parents[0], int(source))
+            self._runcmd(hg.graft, '-c', *uargs)
+
+        return nextrev
+
+def buildgraph(path, textgraph):
+    """create test repostory with dag specified by graphlog like format
+
+    Example:
+        buildgraph('./testrepo', r'''
+            o   # string after '#' is treated as comment
+            |   #
+            4   # <- revno can be used instead of 'o' if revno <= 9
+            |   #
+            o   # <- if 2 parents exist, one under '|' is treated as p1.
+            |\  #
+            o | # some parameters can be specified by below format
+            | o [branch=test user=bob files='foo,bar']
+            |/
+            o
+            ''')
+
+    Revision parameters:
+      branch    branch name. if not specified, use branch of p1
+      user      author. if not specified, use 'alice'
+      files     files to be modified in the revision
+      merge     'local' or 'other'. can be specified to merged revision only
+    """
+    GraphBuilder(path, textgraph).build()

tests/mercurialapi_test.py

 import inspect
 from nose.tools import *
 
+from mercurial import ui as uimod
+
 from tortoisehg.hgqt import thread
+from tortoisehg.util import pipeui
 
 def assert_same_argspec(f, g):
     fa, ga = inspect.getargspec(f), inspect.getargspec(g)
         if name == '__init__':
             continue
         yield assert_same_argspec, basemeth, meth
+
+def test_pipeui():
+    ui = uimod.ui()
+    pipeui.uisetup(ui)
+    for name, basemeth, meth in overridden_methods(ui.__class__):
+        yield assert_same_argspec, basemeth, meth

tests/nosehgenv.py

         del self._qapp
 
         if not self.keep_tmpdir:
-            # TODO: workaround for file lock problem on Windows
-            # https://bitbucket.org/tortoisehg/thg/issue/1783/
-            from tortoisehg.hgqt import thgrepo
-            for e in thgrepo._repocache.itervalues():
-                repoagent = e._pyqtobj
-                repoagent.stopMonitoring()
-
             shutil.rmtree(self.tmpdir)

tests/qt_cmdagent_test.py

 from PyQt4.QtCore import QCoreApplication, QEventLoop, QObject, QTimer
 from PyQt4.QtCore import pyqtSlot
 
-from mercurial import ui
+from mercurial import ui as uimod
 from tortoisehg.hgqt import cmdcore
 
 import helpers
             self._outputs.append(unicode(msg))
 
 
-class CmdAgentTest(unittest.TestCase):
+def waitForCmdStarted(session, timeout=5000):
+    if session.isRunning() or session.isFinished():
+        return
+    loop = QEventLoop()
+    session.controlMessage.connect(loop.quit)  # wait for initial banner
+    QTimer.singleShot(timeout, loop.quit)
+    loop.exec_()
+
+
+class _CmdAgentTestBase(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
         tmpdir = helpers.mktmpdir(cls.__name__)
         hg.commit('-Am', 'add foo')
 
     def setUp(self):
-        self.agent = agent = cmdcore.CmdAgent(ui.ui())
+        ui = uimod.ui()
+        ui.setconfig('tortoisehg', 'cmdworker', self.workername)
+        self.agent = agent = cmdcore.CmdAgent(ui)
         agent.setWorkingDirectory(self.hg.path)
         self.busyChanged = mock.Mock()
         self.commandFinished = mock.Mock()
         agent.busyChanged.connect(self.busyChanged)
         agent.commandFinished.connect(self.commandFinished)
-        # dispatch() may change cwd
-        self.origcwd = os.getcwd()
 
     def tearDown(self):
-        os.chdir(self.origcwd)
+        self.agent.stopService()
+        if self.agent.isServiceRunning():
+            loop = QEventLoop()
+            self.agent.serviceStopped.connect(loop.quit)
+            QTimer.singleShot(5000, loop.quit)  # timeout
+            loop.exec_()
 
     def test_runcommand(self):
         sess = self.agent.runCommand(['root'])
     def test_runcommand_delayedstart(self):
         sess = self.agent.runCommand(['root'])
         self.assertFalse(sess.isRunning())
-        QCoreApplication.processEvents()
+        waitForCmdStarted(sess)
         self.assertTrue(sess.isRunning())
         self._check_runcommand(sess, self.hg.path)
 
     def test_runcommand_queued(self):
         sess1 = self.agent.runCommand(['id', '-i'])
-        QCoreApplication.processEvents()
+        waitForCmdStarted(sess1)
         self.assertTrue(sess1.isRunning())
         sess2 = self.agent.runCommand(['id', '-n'])
         self.assertFalse(sess2.isRunning())
 
         self._check_runcommand(sess1, '53245c60e682')
-        QCoreApplication.processEvents()
+        QCoreApplication.processEvents()  # should start in next loop
         self.assertTrue(sess2.isRunning())
         self._check_runcommand(sess2, '0')
 
     def test_runcommand_signal_chain(self):
         sess = self.agent.runCommand(['id', '-i'])
-        sess.commandFinished.connect(self._chained_runcommand)
+        chainedsessq = []
+        sess.commandFinished.connect(
+            lambda: chainedsessq.append(self.agent.runCommand(['id', '-n'])))
         self._check_runcommand(sess, '53245c60e682')
-
-    @pyqtSlot()
-    def _chained_runcommand(self):
-        sess = self.agent.runCommand(['id', '-n'])
-        QCoreApplication.processEvents()
+        self.assertTrue(chainedsessq)
+        sess = chainedsessq.pop(0)
+        QCoreApplication.processEvents()  # should start in next loop
         self.assertTrue(sess.isRunning())
         self._check_runcommand(sess, '0')
 
         sess = self.agent.runCommand(['log'])
         finished = mock.Mock()
         sess.commandFinished.connect(finished)
-        QCoreApplication.processEvents()
+        waitForCmdStarted(sess)
+        self.assertTrue(sess.isRunning())
         sess.abort()
         self._check_abort_session(sess)
         self.assertEqual(1, finished.call_count)
         sess1.commandFinished.connect(finished.sess1)
         sess2.commandFinished.connect(finished.sess2)
 
+        # waiting session aborts immediately
         sess2.abort()
-        self.assertFalse(sess2.isFinished())
+        self.assertTrue(sess2.isAborted())
+        self.assertTrue(sess2.isFinished())
         CmdWaiter(sess1).wait()
-        self._check_abort_session(sess2)
-        # finished signals should be emitted in order
-        self.assertEqual(['sess1', 'sess2'],
+        # "finished" signal of waiting session should be emitted, because the
+        # session object is known to the client
+        self.assertEqual(['sess2', 'sess1'],
                          [x[0] for x in finished.method_calls])
+        # but agent's signal should be emitted only for the active session
+        self.assertEqual([mock.call(sess1)],
+                         self.commandFinished.call_args_list)
+
+    def test_abort_all_waiting_sessions(self):
+        sessions = map(self.agent.runCommand,
+                       [['id', '-i'], ['id', '-n'], ['root']])
+        self.assertTrue(self.agent.isBusy())
+        self.busyChanged.reset_mock()
+        # abort from waiting one
+        for sess in sessions[1:] + [sessions[0]]:
+            sess.abort()
+            self.assertTrue(sess.isAborted())
+            self.assertTrue(sess.isFinished())
+        # sessions should be deleted from queue immediately
+        self.assertFalse(self.agent.isBusy())
+        self.busyChanged.assert_called_once_with(False)
 
     def test_abortcommands(self):
         sessions = map(self.agent.runCommand,
                        [['id', '-i'], ['id', '-n'], ['root']])
+        waitForCmdStarted(sessions[0])
+        # sess0 (running), sess1 (waiting), ...
         self.agent.abortCommands()
         for sess in sessions:
             self._check_abort_session(sess)
     def _check_busystate_on_finished(self):
         # busy state should be off when the last commandFinished emitted
         self.assertFalse(self.agent.isBusy())
+
+
+class CmdAgentThreadTest(_CmdAgentTestBase):
+    workername = 'thread'
+
+    def setUp(self):
+        super(CmdAgentThreadTest, self).setUp()
+        # dispatch() may change cwd
+        self.origcwd = os.getcwd()
+
+    def tearDown(self):
+        super(CmdAgentThreadTest, self).tearDown()
+        os.chdir(self.origcwd)
+
+
+class CmdAgentProcTest(_CmdAgentTestBase):
+    workername = 'proc'
+
+
+class CmdAgentServerTest(_CmdAgentTestBase):
+    workername = 'server'
+
+    @classmethod
+    def setUpClass(cls):
+        super(CmdAgentServerTest, cls).setUpClass()
+        cls.hg.fwrite('testext.py',
+                      'import sys\n'
+                      'from mercurial import cmdutil\n'
+                      'cmdtable = {}\n'
+                      'command = cmdutil.command(cmdtable)\n'
+                      '@command("writestdout")\n'
+                      'def writestdout(ui, repo, *data):\n'
+                      '    sys.stdout.write("".join(data))\n'
+                      '    sys.stdout.flush()\n')
+        cls.hg.fappend('.hg/hgrc', '[extensions]\ntestext = testext.py\n')
+
+    def test_abort_session_waiting_for_worker(self):
+        sess = self.agent.runCommand(['id', '-i'])
+        QCoreApplication.processEvents()  # start session
+        self.assertTrue(sess._worker)
+        self.assertNotEqual(cmdcore.CmdWorker.Ready,
+                            sess._worker.serviceState())
+        sess.abort()
+        CmdWaiter(sess).wait()
+        self.assertTrue(sess.isAborted())
+
+    def test_runcommand_while_service_stopping(self):
+        sess1 = self.agent.runCommand(['id', '-i'])  # start server
+        CmdWaiter(sess1).wait()
+        worker = self.agent._workers['server']
+        self.assertEqual(cmdcore.CmdWorker.Ready, worker.serviceState())
+        self.agent.stopService()
+        self.assertEqual(cmdcore.CmdWorker.Stopping, worker.serviceState())
+        # start new session while server is shutting down
+        sess2 = self.agent.runCommand(['id', '-n'])
+        CmdWaiter(sess2).wait()
+        self.assertTrue(sess2.isFinished())
+        self.assertEqual(0, sess2.exitCode())  # should not be canceled
+
+    @mock.patch('tortoisehg.util.paths.get_hg_command',
+                return_value=['/inexistent'])
+    def test_server_failed_to_start(self, m):
+        sess = self.agent.runCommand(['id', '-i'])
+        CmdWaiter(sess).wait()
+        self.assertTrue(sess.isFinished())
+        self.assertEqual(-1, sess.exitCode())
+
+    def test_stop_server_by_data_timeout(self):
+        CmdWaiter(self.agent.runCommand(['id'])).wait()  # start server
+        worker = self.agent._workers['server']
+        worker._readtimer.setInterval(100)  # avoid long wait
+        sess = self.agent.runCommand(['writestdout', 'o\0\0\0\1'])
+        CmdWaiter(sess).wait()
+        self.assertEqual(cmdcore.CmdWorker.NotRunning, worker.serviceState())

tests/qt_repomanager_test.py

 import mock, os, unittest
-from mercurial import ui
-from tortoisehg.hgqt import thgrepo
+
+from PyQt4.QtCore import QEventLoop, QTimer
+
+from mercurial import ui as uimod
+from tortoisehg.hgqt import cmdcore, thgrepo
+
+import helpers
 
 def mockrepo(ui, path):
     m = mock.MagicMock(ui=ui, root=path)
     m.unfiltered = lambda: m
     return m
 
+def mockwatcher(repo, parent=None):
+    m = mock.MagicMock()
+    m.isMonitoring.return_value = False
+    return m
+
 if os.name == 'nt':
     def fspath(s):
         if s.startswith('/'):
         return s
 
 LOCAL_SIGNALS = ['repositoryOpened', 'repositoryClosed']
-MAPPED_SIGNALS = ['configChanged', 'repositoryChanged', 'repositoryDestroyed']
+MAPPED_SIGNALS = [
+    # signal name, example arguments
+    ('configChanged', ()),
+    ('repositoryChanged', ()),
+    ('repositoryDestroyed', ()),
+    ('busyChanged', (False,)),
+    ('progressReceived', (cmdcore.ProgressMessage('', None),)),
+    ]
 
 class RepoManagerMockedTest(unittest.TestCase):
     def setUp(self):
         self.hgrepopatcher = mock.patch('mercurial.hg.repository', new=mockrepo)
-        self.watcherpatcher = mock.patch('tortoisehg.hgqt.thgrepo.RepoWatcher')
+        self.watcherpatcher = mock.patch('tortoisehg.hgqt.thgrepo.RepoWatcher',
+                                         new=mockwatcher)
         self.hgrepopatcher.start()
         self.watcherpatcher.start()
-        self.repoman = thgrepo.RepoManager(ui.ui())
+        self.repoman = thgrepo.RepoManager(uimod.ui())
 
-        for signame in LOCAL_SIGNALS + MAPPED_SIGNALS:
+        for signame in LOCAL_SIGNALS + [s for s, _a in MAPPED_SIGNALS]:
             slot = mock.Mock()
             setattr(self, signame, slot)
             getattr(self.repoman, signame).connect(slot)
     def test_signal_map(self):
         p = fspath('/a')
         a = self.repoman.openRepoAgent(p)
-        for signame in MAPPED_SIGNALS:
-            getattr(a, signame).emit()
-            getattr(self, signame).assert_called_once_with(p)
+        for signame, args in MAPPED_SIGNALS:
+            getattr(a, signame).emit(*args)
+            fullargs = (p,) + args
+            getattr(self, signame).assert_called_once_with(*fullargs)
 
     def test_disconnect_signal_on_close(self):
         a = self.repoman.openRepoAgent('/a')
         self.repoman.releaseRepoAgent('/a')
-        for signame in MAPPED_SIGNALS:
-            getattr(a, signame).emit()
+        for signame, args in MAPPED_SIGNALS:
+            getattr(a, signame).emit(*args)
             self.assertFalse(getattr(self, signame).called)
 
     def test_opened_signal(self):
         self.assertFalse(self.repositoryClosed.called)
         self.repoman.releaseRepoAgent(p)
         self.repositoryClosed.assert_called_once_with(p)
+
+
+def waitForRepositoryClosed(repoman, path, timeout=5000):
+    loop = QEventLoop()
+    repoman.repositoryClosed.connect(loop.quit)
+    timer = QTimer(interval=timeout, singleShot=True)
+    timer.timeout.connect(loop.quit)
+    timer.start()
+    while repoman.repoAgent(path) and timer.isActive():
+        loop.exec_()
+
+def waitForServiceStopped(repoagent, timeout=5000):
+    if not repoagent.isServiceRunning():
+        return
+    loop = QEventLoop()
+    repoagent.serviceStopped.connect(loop.quit)
+    QTimer.singleShot(timeout, loop.quit)
+    loop.exec_()
+
+def waitForUnbusy(repoagent, timeout=5000):
+    if not repoagent.isBusy():
+        return
+    loop = QEventLoop()
+    repoagent.busyChanged.connect(loop.quit)
+    QTimer.singleShot(timeout, loop.quit)
+    loop.exec_()
+
+
+class RepoManagerServiceTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        tmpdir = helpers.mktmpdir(cls.__name__)
+        cls.hg = hg = helpers.HgClient(tmpdir)
+        hg.init()
+
+    def setUp(self):
+        ui = uimod.ui()
+        ui.setconfig('tortoisehg', 'cmdworker', 'server')
+        ui.setconfig('tortoisehg', 'monitorrepo', 'never')
+        self.repoman = thgrepo.RepoManager(ui)
+
+    def tearDown(self):
+        if self.repoman.repoAgent(self.hg.path):
+            self.repoman.releaseRepoAgent(self.hg.path)
+            waitForRepositoryClosed(self.repoman, self.hg.path)
+        thgrepo._repocache.clear()
+
+    def test_close(self):
+        a = self.repoman.openRepoAgent(self.hg.path)
+        a.runCommand(['root'])  # start service
+        waitForUnbusy(a)
+        self.assertTrue(a.isServiceRunning())
+        self.repoman.releaseRepoAgent(self.hg.path)
+        self.assertTrue(self.repoman.repoAgent(self.hg.path))
+        waitForRepositoryClosed(self.repoman, self.hg.path)
+        self.assertFalse(self.repoman.repoAgent(self.hg.path))
+
+    def test_reopen_about_to_be_closed(self):
+        a1 = self.repoman.openRepoAgent(self.hg.path)
+        a1.runCommand(['root'])  # start service
+        waitForUnbusy(a1)
+        self.repoman.releaseRepoAgent(self.hg.path)
+        # increase refcount again
+        a2 = self.repoman.openRepoAgent(self.hg.path)
+        self.assertTrue(a1 is a2)
+        # repository should be available after service stopped
+        waitForServiceStopped(a1)
+        self.assertTrue(self.repoman.repoAgent(self.hg.path))

tortoisehg/hgqt/archive.py

     dlg = cmdui.CmdControlDialog(parent)
     dlg.setWindowTitle(_('Archive - %s') % repoagent.displayName())
     dlg.setWindowIcon(qtlib.geticon('hg-archive'))
-    dlg.layout().setSizeConstraint(QLayout.SetMinAndMaxSize)
     dlg.setObjectName('archive')
     dlg.setRunButtonText(_('&Archive'))
     dlg.setCommandWidget(ArchiveWidget(repoagent, rev, dlg))

tortoisehg/hgqt/backout.py

 # This software may be used and distributed according to the terms of the
 # GNU General Public License version 2, incorporated herein by reference.
 
-from mercurial import error
-
 from tortoisehg.util import hglib
 from tortoisehg.hgqt.i18n import _
 from tortoisehg.hgqt import qtlib, csinfo, i18n, cmdcore, cmdui, status, resolve
 from PyQt4.QtCore import *
 from PyQt4.QtGui import *
 
+def checkrev(repo, rev):
+    op1, op2 = repo.dirstate.parents()
+    if op1 is None:
+        return _('Backout requires a parent revision')
+
+    bctx = repo[rev]
+    a = repo.changelog.ancestor(op1, bctx.node())
+    if a != bctx.node():
+        return _('Cannot backout change on a different branch')
+
+
 class BackoutDialog(QWizard):
 
     def __init__(self, repoagent, rev, parent=None):
         super(BackoutDialog, self).__init__(parent)
+        self._repoagent = repoagent
         f = self.windowFlags()
         self.setWindowFlags(f & ~Qt.WindowContextHelpButtonHint)
 
-        self.backoutrev = rev
-        self.parentbackout = False
-        self.backoutmergeparentrev = None
+        repo = repoagent.rawRepo()
+        parentbackout = repo[rev] == repo['.']
 
         self.setWindowTitle(_('Backout - %s') % repoagent.displayName())
         self.setWindowIcon(qtlib.geticon('hg-revert'))
         self.setOption(QWizard.NoBackButtonOnLastPage, True)
         self.setOption(QWizard.IndependentPages, True)
 
-        self.addPage(SummaryPage(repoagent, self))
-        self.addPage(BackoutPage(repoagent, self))
-        self.addPage(CommitPage(repoagent, self))
+        self.addPage(SummaryPage(repoagent, rev, parentbackout, self))
+        self.addPage(BackoutPage(repoagent, rev, parentbackout, self))
+        self.addPage(CommitPage(repoagent, rev, parentbackout, self))
         self.addPage(ResultPage(repoagent, self))
         self.currentIdChanged.connect(self.pageChanged)
 
         repoagent.repositoryChanged.connect(self.repositoryChanged)
         repoagent.configChanged.connect(self.configChanged)
 
+        self._readSettings()
+
+    def _readSettings(self):
+        qs = QSettings()
+        qs.beginGroup('backout')
+        for n in ['autoadvance', 'skiplast']:
+            self.setField(n, qs.value(n, False))
+        repo = self._repoagent.rawRepo()
+        n = 'autoresolve'
+        self.setField(n, repo.ui.configbool('tortoisehg', n,
+                                            qs.value(n, True).toBool()))
+        qs.endGroup()
+
+    def _writeSettings(self):
+        qs = QSettings()
+        qs.beginGroup('backout')
+        for n in ['autoadvance', 'autoresolve', 'skiplast']:
+            qs.setValue(n, self.field(n))
+        qs.endGroup()
+
     @pyqtSlot()
     def repositoryChanged(self):
         self.currentPage().repositoryChanged()
         if self.currentPage().canExit():
             super(BackoutDialog, self).reject()
 
+    def done(self, r):
+        self._writeSettings()
+        super(BackoutDialog, self).done(r)
+
 
 class BasePage(QWizardPage):
     def __init__(self, repoagent, parent):
 
 class SummaryPage(BasePage):
 
-    def __init__(self, repoagent, parent):
+    def __init__(self, repoagent, backoutrev, parentbackout, parent):
         super(SummaryPage, self).__init__(repoagent, parent)
         self._wctxcleaner = wctxcleaner.WctxCleaner(repoagent, self)
         self._wctxcleaner.checkStarted.connect(self._onCheckStarted)
         self._wctxcleaner.checkFinished.connect(self._onCheckFinished)
-
-    def initializePage(self):
-        if self.layout():
-            return
         self.setTitle(_('Prepare to backout'))
         self.setSubTitle(_('Verify backout revision and ensure your working '
                            'directory is clean.'))
         self.groups = qtlib.WidgetGroups()
 
         repo = self.repo
-        try:
-            bctx = repo[self.wizard().backoutrev]