visdiff.py 22 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
37
38
39
from mercurial import (
    copies,
    error,
    match,
    scmutil,
    util,
)
40
41
42
from mercurial.utils import (
    procutil,
)
43

44
45
46
from ..util import hglib
from ..util.i18n import _
from . import qtlib
47
48
49
50
51
52

# Match parent2 first, so 'parent1?' will match both parent1 and parent
_regex = '\$(parent2|parent1?|child|plabel1|plabel2|clabel|repo|phash1|phash2|chash)'

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

53
54
55
56
# 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

57
58
59
60
61
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
62
63
64
65

    global _diffCount
    _diffCount += 1

66
67
68
69
70
71
72
73
    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)
74
    label1a = '@%d:%s' % (ctx1a.rev(), ctx1a)
75
76
77
78
79

    # 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)
80
        label1b = '@%d:%s' % (ctx1b.rev(), ctx1b)
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
    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)
98
        label2 = '@%d:%s' % (ctx2.rev(), ctx2)
99
100
101
102
103
104

    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
105
def snapshot(repo, files, ctx):
106
107
    '''snapshot files as of some revision'''
    dirname = os.path.basename(repo.root) or 'root'
108
    dirname += '.%d' % _diffCount
109
    if ctx.rev() is not None:
110
        dirname += '.%d' % ctx.rev()
Steve Borho's avatar
Steve Borho committed
111
    base = os.path.join(qtlib.gettempdir(), dirname)
112
    fns_and_mtime = []
113
    if not os.path.exists(base):
114
        os.makedirs(base)
115
116
117
118
119
120
    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)
121
122
123
        if os.path.exists(dest):
            # File has already been snapshot
            continue
124
        destdir = os.path.dirname(dest)
125
126
127
        try:
            if not os.path.isdir(destdir):
                os.makedirs(destdir)
128
129
            fctx = ctx[wfn]
            data = repo.wwritedata(wfn, fctx.data())
130
131
132
            f = open(dest, 'wb')
            f.write(data)
            f.close()
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
147
    return base, fns_and_mtime

def launchtool(cmd, opts, replace, block):
    def quote(match):
        key = match.group()[1:]
148
        return procutil.shellquote(replace[key])
149
150
151
152
153
154
155
156
157
    if isinstance(cmd, unicode):
        cmd = hglib.fromunicode(cmd)
    lopts = []
    for opt in opts:
        if isinstance(opt, unicode):
            lopts.append(hglib.fromunicode(opt))
        else:
            lopts.append(opt)
    args = ' '.join(lopts)
158
    args = re.sub(_regex, quote, args)
159
    cmdline = procutil.shellquote(cmd) + ' ' + 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
200
        try:
            p = ui.configint('merge-tools', t + '.priority', 0)
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
240
241
242
243
    m = match.match(repo.root, '', pats, None, None, 'relpath')
    n2 = ctx2.node()
    mod_a, add_a, rem_a = map(set, repo.status(ctx1a.node(), n2, m)[:3])
    if ctx1b:
        mod_b, add_b, rem_b = map(set, repo.status(ctx1b.node(), n2, m)[:3])
244
        cpy = copies.mergecopies(repo, ctx1a, ctx1b, ctx1a.ancestor(ctx1b))[0]
245
    else:
246
        cpy = copies.pathcopies(ctx1a, ctx2)
247
248
249
250
        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:
251
        QMessageBox.information(None,
252
253
254
255
256
257
                _('No file changes'),
                _('There are no file changes to view'))
        return None

    detectedtools = hglib.difftools(repo.ui)
    if not detectedtools:
258
        QMessageBox.warning(None,
259
260
261
262
                _('No diff tool found'),
                _('No visual diff tools were detected'))
        return None

263
    preferred = besttool(repo.ui, detectedtools, opts.get('tool'))
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279

    # 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:
            mf = match.match(repo.root, '', [pat])
            if mf(path):
                toollist.add(tool)
                break
        else:
            toollist.add(preferred)

    cto = cpy.keys()
    for path in MAR:
280
        if path in cto:
281
282
283
284
285
            hascopies = True
            break
    else:
        hascopies = False
    force = repo.ui.configbool('tortoisehg', 'forcevdiffwin')
286
    if len(toollist) > 1 or (hascopies and len(MAR) > 1) or force:
287
288
289
290
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
        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
321
    def dodiff():
322
323
        assert not (hascopies and len(MAR) > 1), \
                'dodiff cannot handle copies when diffing dirs'
324

325
326
327
328
329
330
        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
331
        dirs, labels, fns_and_mtimes = snapshotset(repo, ctxs, sa, sb, cpy,
332
333
334
335
336
337
                                                   copyworkingdir)
        dir1a, dir1b, dir2 = dirs
        label1a, label1b, label2 = labels
        fns_and_mtime = fns_and_mtimes[2]

        if len(MAR) > 1 and label2 == '':
338
339
340
            label2 = 'working files'

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

        # 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:
353
354
            file2 = MAR.pop()
            file2local = util.localpath(file2)
355
356
357
358
359
            if file2 in cto:
                file1 = util.localpath(cpy[file2])
            else:
                file1 = file2
            label1a, dir1a = getfile(file1, dir1a, label1a)
360
            if do3way:
361
                label1b, dir1b = getfile(file1, dir1b, label1b)
362
            label2, dir2 = getfile(file2local, dir2, label2)
363
364
365
366
367
        if do3way:
            label1a += '[local]'
            label1b += '[other]'
            label2 += '[merged]'

368
        repoagent = repo._pyqtobj  # TODO
369
370
371
        replace = dict(parent=dir1a, parent1=dir1a, parent2=dir1b,
                       plabel1=label1a, plabel2=label1b,
                       phash1=str(ctx1a), phash2=str(ctx1b),
372
                       repo=hglib.fromunicode(repoagent.displayName()),
373
374
375
376
377
                       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:
378
            try:
379
                if os.lstat(copy_fn).st_mtime != mtime:
380
381
382
383
384
                    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
385
386
387

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

    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()

402
class FileSelectionDialog(QDialog):
403
404
405
    'Dialog for selecting visual diff candidates'
    def __init__(self, repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy):
        'Initialize the Dialog'
406
        QDialog.__init__(self)
407

408
        self.setWindowIcon(qtlib.geticon('visualdiff'))
409
410
411
412

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

422
        self.resize(650, 250)
423
424
        repoagent = repo._pyqtobj  # TODO
        self.reponame = hglib.fromunicode(repoagent.displayName())
425
426

        self.ctxs = (ctx1a, ctx1b, ctx2)
427
        self.filesets = (sa, sb)
428
        self.copies = cpy
429
430
        self.repo = repo
        self.curFile = None
431

432
        layout = QVBoxLayout()
433
434
        self.setLayout(layout)

435
436
        lbl = QLabel(_('Temporary files are removed when this dialog '
                       'is closed'))
437
438
        layout.addWidget(lbl)

439
        list = QListWidget()
440
441
        layout.addWidget(list)
        self.list = list
442
        list.itemActivated.connect(self.itemActivated)
443
444
445
446

        tools = hglib.difftools(repo.ui)
        preferred = besttool(repo.ui, tools)
        self.diffpath, self.diffopts, self.mergeopts = tools[preferred]
447
        self.tools = tools
448
        self.preferred = preferred
449
450

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

464
            list.currentRowChanged.connect(self.updateToolSelection)
465
            combo.currentIndexChanged[str].connect(self.onToolSelected)
466
            self.toolCombo = combo
467

468
        BB = QDialogButtonBox
469
470
        bb = BB()
        layout.addWidget(bb)
471
472
473
474
475
476

        if ctx2.rev() is None:
            pass
            # Do not offer directory diffs when the working directory
            # is being referenced directly
        elif ctx1b:
477
478
479
480
481
482
            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)
483
        else:
484
485
            self.dbutton = bb.addButton(_('Directory diff'), BB.ActionRole)
            self.dbutton.pressed.connect(self.p1dirdiff)
486

487
        self.updateDiffButtons(preferred)
488

489
        QShortcut(QKeySequence('CTRL+D'), self.list, self.activateCurrent)
490
        QTimer.singleShot(0, self.fillmodel)
491

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

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

507
        mod_a, add_a, rem_a = sa
508
        for f in sorted(mod_a | add_a | rem_a):
509
            status = get_status(f, mod_a, add_a, rem_a)
510
            row = '%s %s' % (status, hglib.tounicode(f))
511
            self.list.addItem(row)
512

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

521
522
    @pyqtSlot(int)
    def updateToolSelection(self, row):
523
        'user selected a file, pick an appropriate tool from combo'
524
525
526
527
528
        if row == -1:
            return

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

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

547
548
549
550
551
552
    def activateCurrent(self):
        'CTRL+D has been pressed'
        row = self.list.currentRow()
        if row >= 0:
            self.launch(self.list.item(row).text()[2:])

553
554
555
    def itemActivated(self, item):
        'A QListWidgetItem has been activated'
        self.launch(item.text()[2:])
556

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

569
570
    def launch(self, fname):
        fname = hglib.fromunicode(fname)
571
572
573
574
575
576
577
578
579
580
581
582
583
584
        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
585
                nullfile = os.path.join(qtlib.gettempdir(), 'empty')
586
587
                fp = open(nullfile, 'w')
                fp.close()
588
                return hglib.fromunicode(_nonexistant, 'replace'), nullfile
589
590
591
592
593

        local, file1a = getfile(ctx1a, dir1a, fname, source)
        if ctx1b:
            other, file1b = getfile(ctx1b, dir1b, fname, source)
        else:
Steve Borho's avatar
Steve Borho committed
594
            other, file1b = fname, None
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
        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)

614
    def p1dirdiff(self):
615
616
617
618
619
620
621
622
623
624
        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)

625
    def p2dirdiff(self):
626
627
628
629
630
631
632
633
634
635
        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)

636
    def threewaydirdiff(self):
637
638
639
640
641
642
643
644
645
        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)