visdiff.py 22.2 KB
Newer Older
1
2
3
4
5
6
7
# visdiff.py - launch external visual diff tools
#
# Copyright 2009 Steve Borho <steve@borho.org>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2, incorporated herein by reference.

8
9
from __future__ import absolute_import

10
import os
11
import re
12
import stat
13
import subprocess
14
15
import threading

16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from .qtcore import (
    QTimer,
    pyqtSlot,
)
from .qtgui import (
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QHBoxLayout,
    QKeySequence,
    QLabel,
    QListWidget,
    QMessageBox,
    QShortcut,
    QVBoxLayout,
)
32

33
34
35
36
from mercurial import (
    copies,
    error,
    match,
37
    pycompat,
38
39
40
    scmutil,
    util,
)
41
42
43
from mercurial.utils import (
    procutil,
)
44

45
46
47
from ..util import hglib
from ..util.i18n import _
from . import qtlib
48
49

# Match parent2 first, so 'parent1?' will match both parent1 and parent
kiilerix's avatar
kiilerix committed
50
_regex = b'\$(parent2|parent1?|child|plabel1|plabel2|clabel|repo|phash1|phash2|chash)'
51
52
53

_nonexistant = _('[non-existant]')

54
55
56
57
# This global counter is incremented for each visual diff done in a session
# It ensures that the names for snapshots created do not collide.
_diffCount = 0

58
59
60
61
62
def snapshotset(repo, ctxs, sa, sb, copies, copyworkingdir = False):
    '''snapshot files from parent-child set of revisions'''
    ctx1a, ctx1b, ctx2 = ctxs
    mod_a, add_a, rem_a = sa
    mod_b, add_b, rem_b = sb
63
64
65
66

    global _diffCount
    _diffCount += 1

67
68
69
70
71
72
73
74
    if copies:
        sources = set(copies.values())
    else:
        sources = set()

    # Always make a copy of ctx1a
    files1a = sources | mod_a | rem_a | ((mod_b | add_b) - add_a)
    dir1a, fns_mtime1a = snapshot(repo, files1a, ctx1a)
75
    label1a = '@%d:%s' % (ctx1a.rev(), ctx1a)
76
77
78
79
80

    # Make a copy of ctx1b if relevant
    if ctx1b:
        files1b = sources | mod_b | rem_b | ((mod_a | add_a) - add_b)
        dir1b, fns_mtime1b = snapshot(repo, files1b, ctx1b)
81
        label1b = '@%d:%s' % (ctx1b.rev(), ctx1b)
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
    else:
        dir1b = None
        fns_mtime1b = []
        label1b = ''

    # Either make a copy of ctx2, or use working dir directly if relevant.
    files2 = mod_a | add_a | mod_b | add_b
    if ctx2.rev() is None:
        if copyworkingdir:
            dir2, fns_mtime2 = snapshot(repo, files2, ctx2)
        else:
            dir2 = repo.root
            fns_mtime2 = []
        # If ctx2 is working copy, use empty label.
        label2 = ''
    else:
        dir2, fns_mtime2 = snapshot(repo, files2, ctx2)
99
        label2 = '@%d:%s' % (ctx2.rev(), ctx2)
100
101
102
103
104
105

    dirs = [dir1a, dir1b, dir2]
    labels = [label1a, label1b, label2]
    fns_and_mtimes = [fns_mtime1a, fns_mtime1b, fns_mtime2]
    return dirs, labels, fns_and_mtimes

Steve Borho's avatar
Steve Borho committed
106
def snapshot(repo, files, ctx):
107
108
    '''snapshot files as of some revision'''
    dirname = os.path.basename(repo.root) or 'root'
109
    dirname += '.%d' % _diffCount
110
    if ctx.rev() is not None:
111
        dirname += '.%d' % ctx.rev()
Steve Borho's avatar
Steve Borho committed
112
    base = os.path.join(qtlib.gettempdir(), dirname)
113
    fns_and_mtime = []
114
    if not os.path.exists(base):
115
        os.makedirs(base)
116
117
118
119
120
121
    for fn in files:
        wfn = util.pconvert(fn)
        if not wfn in ctx:
            # File doesn't exist; could be a bogus modify
            continue
        dest = os.path.join(base, wfn)
122
123
124
        if os.path.exists(dest):
            # File has already been snapshot
            continue
125
        destdir = os.path.dirname(dest)
126
127
128
        try:
            if not os.path.isdir(destdir):
                os.makedirs(destdir)
129
130
            fctx = ctx[wfn]
            data = repo.wwritedata(wfn, fctx.data())
131
132
            with open(dest, 'wb') as f:
                f.write(data)
133
134
            if 'x' in fctx.flags():
                util.setflags(dest, False, True)
135
136
137
138
139
140
141
142
            if ctx.rev() is None:
                fns_and_mtime.append((dest, repo.wjoin(fn), 
                                    os.lstat(dest).st_mtime))
            else:
                # Make file read/only, to indicate it's static (archival) nature
                os.chmod(dest, stat.S_IREAD)
        except EnvironmentError:
            pass
143
144
145
146
    return base, fns_and_mtime

def launchtool(cmd, opts, replace, block):
    def quote(match):
kiilerix's avatar
kiilerix committed
147
        key = pycompat.sysstr(match.group()[1:])
148
        return procutil.shellquote(replace[key])
149
    if isinstance(cmd, pycompat.unicode):
150
151
152
        cmd = hglib.fromunicode(cmd)
    lopts = []
    for opt in opts:
153
        if isinstance(opt, pycompat.unicode):
154
155
156
            lopts.append(hglib.fromunicode(opt))
        else:
            lopts.append(opt)
kiilerix's avatar
kiilerix committed
157
    args = b' '.join(lopts)
158
    args = re.sub(_regex, quote, args)
kiilerix's avatar
kiilerix committed
159
    cmdline = procutil.shellquote(cmd) + b' ' + args
160
    cmdline = procutil.quotecommand(cmdline)
161
162
    try:
        proc = subprocess.Popen(cmdline, shell=True,
163
                                creationflags=qtlib.openflags,
164
165
166
167
168
                                stderr=subprocess.PIPE,
                                stdout=subprocess.PIPE,
                                stdin=subprocess.PIPE)
        if block:
            proc.communicate()
169
    except (OSError, EnvironmentError) as e:
170
        QMessageBox.warning(None,
171
172
173
174
175
176
177
                _('Tool launch failure'),
                _('%s : %s') % (cmd, str(e)))

def filemerge(ui, fname, patchedfname):
    'Launch the preferred visual diff tool for two text files'
    detectedtools = hglib.difftools(ui)
    if not detectedtools:
178
        QMessageBox.warning(None,
179
180
181
182
183
184
185
186
187
188
189
190
                _('No diff tool found'),
                _('No visual diff tools were detected'))
        return None
    preferred = besttool(ui, detectedtools)
    diffcmd, diffopts, mergeopts = detectedtools[preferred]
    replace = dict(parent=fname, parent1=fname,
                   plabel1=fname + _('[working copy]'),
                   repo='', phash1='', phash2='', chash='',
                   child=patchedfname, clabel=_('[original]'))
    launchtool(diffcmd, diffopts, replace, True)


191
def besttool(ui, tools, force=None):
192
    'Select preferred or highest priority tool from dictionary'
193
194
    preferred = force or ui.config('tortoisehg', 'vdiff') or \
                         ui.config('ui', 'merge')
195
196
197
198
    if preferred and preferred in tools:
        return preferred
    pris = []
    for t in tools.keys():
199
        try:
200
            p = ui.configint('merge-tools', t + '.priority')
201
        except error.ConfigError as inst:
202
203
            ui.warn('visdiff: %s\n' % inst)
            p = 0
204
205
206
207
208
209
        pris.append((-p, t))
    tools = sorted(pris)
    return tools[0][1]


def visualdiff(ui, repo, pats, opts):
210
    revs = opts.get('rev', [])
211
212
213
214
215
    change = opts.get('change')

    try:
        ctx1b = None
        if change:
216
217
218
219
220
            # TODO: figure out what's the expect type
            if isinstance(change,  str):
                ctx2 = scmutil.revsymbol(repo, change)
            else:
                ctx2 = repo[change]
221
222
223
224
225
226
            p = ctx2.parents()
            if len(p) > 1:
                ctx1a, ctx1b = p
            else:
                ctx1a = p[0]
        else:
227
            n1, n2 = scmutil.revpair(repo, revs)
228
229
230
231
232
            ctx1a, ctx2 = repo[n1], repo[n2]
            p = ctx2.parents()
            if not revs and len(p) > 1:
                ctx1b = p[1]
    except (error.LookupError, error.RepoError):
233
        QMessageBox.warning(None,
234
235
236
237
                       _('Unable to find changeset'),
                       _('You likely need to refresh this application'))
        return None

238
    pats = scmutil.expandpats(pats)
239
    m = match.match(repo.root, b'', pats, None, None, b'relpath')
240
    n2 = ctx2.node()
241
242
243
    mod_a, add_a, rem_a = pycompat.maplist(set,
                                           repo.status(ctx1a.node(),
                                                       n2, m)[:3])
244
    if ctx1b:
245
246
247
        mod_b, add_b, rem_b = pycompat.maplist(set,
                                               repo.status(ctx1b.node(),
                                                           n2, m)[:3])
248
        cpy = copies.mergecopies(repo, ctx1a, ctx1b, ctx1a.ancestor(ctx1b))[0]
249
    else:
250
        cpy = copies.pathcopies(ctx1a, ctx2)
251
252
253
254
        mod_b, add_b, rem_b = set(), set(), set()
    MA = mod_a | add_a | mod_b | add_b
    MAR = MA | rem_a | rem_b
    if not MAR:
255
        QMessageBox.information(None,
256
257
258
259
260
261
                _('No file changes'),
                _('There are no file changes to view'))
        return None

    detectedtools = hglib.difftools(repo.ui)
    if not detectedtools:
262
        QMessageBox.warning(None,
263
264
265
266
                _('No diff tool found'),
                _('No visual diff tools were detected'))
        return None

267
    preferred = besttool(repo.ui, detectedtools, opts.get('tool'))
268
269
270
271
272
273
274

    # Build tool list based on diff-patterns matches
    toollist = set()
    patterns = repo.ui.configitems('diff-patterns')
    patterns = [(p, t) for p,t in patterns if t in detectedtools]
    for path in MAR:
        for pat, tool in patterns:
275
            mf = match.match(repo.root, b'', [pat])
276
277
278
279
280
281
            if mf(path):
                toollist.add(tool)
                break
        else:
            toollist.add(preferred)

282
    cto = list(cpy.keys())
283
    for path in MAR:
284
        if path in cto:
285
286
287
288
289
            hascopies = True
            break
    else:
        hascopies = False
    force = repo.ui.configbool('tortoisehg', 'forcevdiffwin')
290
    if len(toollist) > 1 or (hascopies and len(MAR) > 1) or force:
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
        usewin = True
    else:
        preferred = toollist.pop()
        dirdiff = repo.ui.configbool('merge-tools', preferred + '.dirdiff')
        dir3diff = repo.ui.configbool('merge-tools', preferred + '.dir3diff')
        usewin = repo.ui.configbool('merge-tools', preferred + '.usewin')
        if not usewin and len(MAR) > 1:
            if ctx1b is not None:
                usewin = not dir3diff
            else:
                usewin = not dirdiff
    if usewin:
        # Multiple required tools, or tool does not support directory diffs
        sa = [mod_a, add_a, rem_a]
        sb = [mod_b, add_b, rem_b]
        dlg = FileSelectionDialog(repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy)
        return dlg

    # We can directly use the selected tool, without a visual diff window
    diffcmd, diffopts, mergeopts = detectedtools[preferred]

    # Disable 3-way merge if there is only one parent or no tool support
    do3way = False
    if ctx1b:
        if mergeopts:
            do3way = True
            args = mergeopts
        else:
            args = diffopts
            if str(ctx1b.rev()) in revs:
                ctx1a = ctx1b
    else:
        args = diffopts

Steve Borho's avatar
Steve Borho committed
325
    def dodiff():
326
327
        assert not (hascopies and len(MAR) > 1), \
                'dodiff cannot handle copies when diffing dirs'
328

329
330
331
332
333
334
        sa = [mod_a, add_a, rem_a]
        sb = [mod_b, add_b, rem_b]
        ctxs = [ctx1a, ctx1b, ctx2]

        # If more than one file, diff on working dir copy.
        copyworkingdir = len(MAR) > 1
335
        dirs, labels, fns_and_mtimes = snapshotset(repo, ctxs, sa, sb, cpy,
336
337
338
339
340
341
                                                   copyworkingdir)
        dir1a, dir1b, dir2 = dirs
        label1a, label1b, label2 = labels
        fns_and_mtime = fns_and_mtimes[2]

        if len(MAR) > 1 and label2 == '':
342
343
344
            label2 = 'working files'

        def getfile(fname, dir, label):
Steve Borho's avatar
Steve Borho committed
345
            file = os.path.join(qtlib.gettempdir(), dir, fname)
346
347
            if os.path.isfile(file):
                return fname+label, file
Steve Borho's avatar
Steve Borho committed
348
            nullfile = os.path.join(qtlib.gettempdir(), 'empty')
349
350
            fp = open(nullfile, 'w')
            fp.close()
351
352
            return (hglib.fromunicode(_nonexistant, 'replace') + label,
                    nullfile)
353
354
355
356

        # If only one change, diff the files instead of the directories
        # Handle bogus modifies correctly by checking if the files exist
        if len(MAR) == 1:
357
358
            file2 = MAR.pop()
            file2local = util.localpath(file2)
359
360
361
362
363
            if file2 in cto:
                file1 = util.localpath(cpy[file2])
            else:
                file1 = file2
            label1a, dir1a = getfile(file1, dir1a, label1a)
364
            if do3way:
365
                label1b, dir1b = getfile(file1, dir1b, label1b)
366
            label2, dir2 = getfile(file2local, dir2, label2)
367
368
369
370
371
        if do3way:
            label1a += '[local]'
            label1b += '[other]'
            label2 += '[merged]'

372
        repoagent = repo._pyqtobj  # TODO
373
374
375
        replace = dict(parent=dir1a, parent1=dir1a, parent2=dir1b,
                       plabel1=label1a, plabel2=label1b,
                       phash1=str(ctx1a), phash2=str(ctx1b),
376
                       repo=hglib.fromunicode(repoagent.displayName()),
377
378
379
380
381
                       clabel=label2, child=dir2, chash=str(ctx2))
        launchtool(diffcmd, args, replace, True)

        # detect if changes were made to mirrored working files
        for copy_fn, working_fn, mtime in fns_and_mtime:
382
            try:
383
                if os.lstat(copy_fn).st_mtime != mtime:
384
385
386
387
388
                    ui.debug('file changed while diffing. '
                            'Overwriting: %s (src: %s)\n' % (working_fn, copy_fn))
                    util.copyfile(copy_fn, working_fn)
            except EnvironmentError:
                pass # Ignore I/O errors or missing files
389
390
391

    def dodiffwrapper():
        try:
Steve Borho's avatar
Steve Borho committed
392
            dodiff()
393
        finally:
Steve Borho's avatar
Steve Borho committed
394
            # cleanup happens atexit
395
            ui.note('cleaning up temp directory\n')
396
397
398
399
400
401
402
403
404
405

    if opts.get('mainapp'):
        dodiffwrapper()
    else:
        # We are not the main application, so this must be done in a
        # background thread
        thread = threading.Thread(target=dodiffwrapper, name='visualdiff')
        thread.setDaemon(True)
        thread.start()

406
class FileSelectionDialog(QDialog):
407
408
409
    'Dialog for selecting visual diff candidates'
    def __init__(self, repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy):
        'Initialize the Dialog'
410
        QDialog.__init__(self)
411

412
        self.setWindowIcon(qtlib.geticon('visualdiff'))
413
414
415
416

        if ctx2.rev() is None:
            title = _('working changes')
        elif ctx1a == ctx2.parents()[0]:
417
            title = _('changeset %d:%s') % (ctx2.rev(), ctx2)
418
        else:
419
420
            title = _('revisions %d:%s to %d:%s') \
                % (ctx1a.rev(), ctx1a, ctx2.rev(), ctx2)
421
422
423
        title = _('Visual Diffs - ') + title
        if pats:
            title += _(' filtered')
424
        self.setWindowTitle(title)
425

426
        self.resize(650, 250)
427
428
        repoagent = repo._pyqtobj  # TODO
        self.reponame = hglib.fromunicode(repoagent.displayName())
429
430

        self.ctxs = (ctx1a, ctx1b, ctx2)
431
        self.filesets = (sa, sb)
432
        self.copies = cpy
433
434
        self.repo = repo
        self.curFile = None
435

436
        layout = QVBoxLayout()
437
438
        self.setLayout(layout)

439
440
        lbl = QLabel(_('Temporary files are removed when this dialog '
                       'is closed'))
441
442
        layout.addWidget(lbl)

443
        list = QListWidget()
444
445
        layout.addWidget(list)
        self.list = list
446
        list.itemActivated.connect(self.itemActivated)
447
448
449
450

        tools = hglib.difftools(repo.ui)
        preferred = besttool(repo.ui, tools)
        self.diffpath, self.diffopts, self.mergeopts = tools[preferred]
451
        self.tools = tools
452
        self.preferred = preferred
453
454

        if len(tools) > 1:
455
            hbox = QHBoxLayout()
456
457
            combo = QComboBox()
            lbl = QLabel(_('Select Tool:'))
458
459
460
461
            lbl.setBuddy(combo)
            hbox.addWidget(lbl)
            hbox.addWidget(combo, 1)
            layout.addLayout(hbox)
462
            for i, name in enumerate(tools.keys()):
463
464
465
466
467
                combo.addItem(name)
                if name == preferred:
                    defrow = i
            combo.setCurrentIndex(defrow)

468
            list.currentRowChanged.connect(self.updateToolSelection)
469
            combo.currentIndexChanged[str].connect(self.onToolSelected)
470
            self.toolCombo = combo
471

472
        BB = QDialogButtonBox
473
474
        bb = BB()
        layout.addWidget(bb)
475
476
477
478
479
480

        if ctx2.rev() is None:
            pass
            # Do not offer directory diffs when the working directory
            # is being referenced directly
        elif ctx1b:
481
482
483
484
485
486
            self.p1button = bb.addButton(_('Dir diff to p1'), BB.ActionRole)
            self.p1button.pressed.connect(self.p1dirdiff)
            self.p2button = bb.addButton(_('Dir diff to p2'), BB.ActionRole)
            self.p2button.pressed.connect(self.p2dirdiff)
            self.p3button = bb.addButton(_('3-way dir diff'), BB.ActionRole)
            self.p3button.pressed.connect(self.threewaydirdiff)
487
        else:
488
489
            self.dbutton = bb.addButton(_('Directory diff'), BB.ActionRole)
            self.dbutton.pressed.connect(self.p1dirdiff)
490

491
        self.updateDiffButtons(preferred)
492

493
        QShortcut(QKeySequence('CTRL+D'), self.list, self.activateCurrent)
494
        QTimer.singleShot(0, self.fillmodel)
495

496
497
498
499
    @pyqtSlot()
    def fillmodel(self):
        repo = self.repo
        sa, sb = self.filesets
500
        self.dirs, self.revs = snapshotset(repo, self.ctxs, sa, sb, self.copies)[:2]
501
502
503
504
505
506
507
508
509
510

        def get_status(file, mod, add, rem):
            if file in mod:
                return 'M'
            if file in add:
                return 'A'
            if file in rem:
                return 'R'
            return ' '

511
        mod_a, add_a, rem_a = sa
512
        for f in sorted(mod_a | add_a | rem_a):
513
            status = get_status(f, mod_a, add_a, rem_a)
514
            row = '%s %s' % (status, hglib.tounicode(f))
515
            self.list.addItem(row)
516

517
    @pyqtSlot(str)
518
    def onToolSelected(self, tool):
519
        'user selected a tool from the tool combo'
520
        tool = hglib.fromunicode(tool)
521
        assert tool in self.tools, tool
522
523
        self.diffpath, self.diffopts, self.mergeopts = self.tools[tool]
        self.updateDiffButtons(tool)
524

525
526
    @pyqtSlot(int)
    def updateToolSelection(self, row):
527
        'user selected a file, pick an appropriate tool from combo'
528
529
530
531
532
        if row == -1:
            return

        repo = self.repo
        patterns = repo.ui.configitems('diff-patterns')
533
        patterns = [(p, t) for p,t in patterns if t in self.tools]
534

535
536
537
538
539
        fname = self.list.item(row).text()[2:]
        fname = hglib.fromunicode(fname)
        if self.curFile == fname:
            return
        self.curFile = fname
540
        for pat, tool in patterns:
541
            mf = match.match(repo.root, b'', [pat])
542
543
544
545
            if mf(fname):
                selected = tool
                break
        else:
546
            selected = self.preferred
547
        for i, name in enumerate(self.tools.keys()):
548
            if name == selected:
549
                self.toolCombo.setCurrentIndex(i)
550

551
552
553
554
555
556
    def activateCurrent(self):
        'CTRL+D has been pressed'
        row = self.list.currentRow()
        if row >= 0:
            self.launch(self.list.item(row).text()[2:])

557
558
559
    def itemActivated(self, item):
        'A QListWidgetItem has been activated'
        self.launch(item.text()[2:])
560

561
    def updateDiffButtons(self, tool):
562
        # hg>=4.4: configbool() may return None as the default is set to None
563
564
565
        if hasattr(self, 'p1button'):
            d2 = self.repo.ui.configbool('merge-tools', tool + '.dirdiff')
            d3 = self.repo.ui.configbool('merge-tools', tool + '.dir3diff')
566
567
568
            self.p1button.setEnabled(bool(d2))
            self.p2button.setEnabled(bool(d2))
            self.p3button.setEnabled(bool(d3))
569
570
        elif hasattr(self, 'dbutton'):
            d2 = self.repo.ui.configbool('merge-tools', tool + '.dirdiff')
571
            self.dbutton.setEnabled(bool(d2))
572

573
574
    def launch(self, fname):
        fname = hglib.fromunicode(fname)
575
576
577
578
579
580
581
582
583
584
585
586
587
588
        source = self.copies.get(fname, None)
        dir1a, dir1b, dir2 = self.dirs
        rev1a, rev1b, rev2 = self.revs
        ctx1a, ctx1b, ctx2 = self.ctxs

        def getfile(ctx, dir, fname, source):
            m = ctx.manifest()
            if fname in m:
                path = os.path.join(dir, util.localpath(fname))
                return fname, path
            elif source and source in m:
                path = os.path.join(dir, util.localpath(source))
                return source, path
            else:
Steve Borho's avatar
Steve Borho committed
589
                nullfile = os.path.join(qtlib.gettempdir(), 'empty')
590
591
                fp = open(nullfile, 'w')
                fp.close()
592
                return hglib.fromunicode(_nonexistant, 'replace'), nullfile
593
594
595
596
597

        local, file1a = getfile(ctx1a, dir1a, fname, source)
        if ctx1b:
            other, file1b = getfile(ctx1b, dir1b, fname, source)
        else:
Steve Borho's avatar
Steve Borho committed
598
            other, file1b = fname, None
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
        fname, file2 = getfile(ctx2, dir2, fname, None)

        label1a = local+rev1a
        label1b = other+rev1b
        label2 = fname+rev2
        if ctx1b:
            label1a += '[local]'
            label1b += '[other]'
            label2 += '[merged]'

        # Function to quote file/dir names in the argument string
        replace = dict(parent=file1a, parent1=file1a, plabel1=label1a,
                       parent2=file1b, plabel2=label1b,
                       repo=self.reponame,
                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
                       clabel=label2, child=file2)
        args = ctx1b and self.mergeopts or self.diffopts
        launchtool(self.diffpath, args, replace, False)

618
    def p1dirdiff(self):
619
620
621
622
623
624
625
626
627
628
        dir1a, dir1b, dir2 = self.dirs
        rev1a, rev1b, rev2 = self.revs
        ctx1a, ctx1b, ctx2 = self.ctxs

        replace = dict(parent=dir1a, parent1=dir1a, plabel1=rev1a,
                       repo=self.reponame,
                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
                       parent2='', plabel2='', clabel=rev2, child=dir2)
        launchtool(self.diffpath, self.diffopts, replace, False)

629
    def p2dirdiff(self):
630
631
632
633
634
635
636
637
638
639
        dir1a, dir1b, dir2 = self.dirs
        rev1a, rev1b, rev2 = self.revs
        ctx1a, ctx1b, ctx2 = self.ctxs

        replace = dict(parent=dir1b, parent1=dir1b, plabel1=rev1b,
                       repo=self.reponame,
                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
                       parent2='', plabel2='', clabel=rev2, child=dir2)
        launchtool(self.diffpath, self.diffopts, replace, False)

640
    def threewaydirdiff(self):
641
642
643
644
645
646
647
648
649
        dir1a, dir1b, dir2 = self.dirs
        rev1a, rev1b, rev2 = self.revs
        ctx1a, ctx1b, ctx2 = self.ctxs

        replace = dict(parent=dir1a, parent1=dir1a, plabel1=rev1a,
                       repo=self.reponame,
                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
                       parent2=dir1b, plabel2=rev1b, clabel=dir2, child=rev2)
        launchtool(self.diffpath, self.mergeopts, replace, False)