visdiff.py 22.5 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):
kiilerix's avatar
kiilerix committed
107
108
109
    '''snapshot repo files as of some revision, returning a tuple with the
    created temporary snapshot dir and tuples of file info if using working
    copy.'''
110
111
    dirname = os.path.basename(repo.root) or b'root'
    dirname += b'.%d' % _diffCount
112
    if ctx.rev() is not None:
113
        dirname += b'.%d' % ctx.rev()
Steve Borho's avatar
Steve Borho committed
114
    base = os.path.join(qtlib.gettempdir(), dirname)
115
    fns_and_mtime = []
116
    if not os.path.exists(base):
117
        os.makedirs(base)
118
    for fn in files:
kiilerix's avatar
kiilerix committed
119
        assert isinstance(fn, bytes), repr(fn)
120
        wfn = util.pconvert(fn)
121
        if wfn not in ctx:
122
123
124
            # File doesn't exist; could be a bogus modify
            continue
        dest = os.path.join(base, wfn)
125
126
127
        if os.path.exists(dest):
            # File has already been snapshot
            continue
128
        destdir = os.path.dirname(dest)
129
130
131
        try:
            if not os.path.isdir(destdir):
                os.makedirs(destdir)
132
133
            fctx = ctx[wfn]
            data = repo.wwritedata(wfn, fctx.data())
134
135
            with open(dest, 'wb') as f:
                f.write(data)
kiilerix's avatar
kiilerix committed
136
            if b'x' in fctx.flags():
137
                util.setflags(dest, False, True)
138
139
140
141
142
143
144
145
            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
146
147
148
149
    return base, fns_and_mtime

def launchtool(cmd, opts, replace, block):
    def quote(match):
kiilerix's avatar
kiilerix committed
150
        key = pycompat.sysstr(match.group()[1:])
151
        return procutil.shellquote(replace[key])
152
    if isinstance(cmd, pycompat.unicode):
153
154
155
        cmd = hglib.fromunicode(cmd)
    lopts = []
    for opt in opts:
156
        if isinstance(opt, pycompat.unicode):
157
158
159
            lopts.append(hglib.fromunicode(opt))
        else:
            lopts.append(opt)
kiilerix's avatar
kiilerix committed
160
    args = b' '.join(lopts)
161
    args = re.sub(_regex, quote, args)
kiilerix's avatar
kiilerix committed
162
    cmdline = procutil.shellquote(cmd) + b' ' + args
163
    cmdline = procutil.quotecommand(cmdline)
164
165
    try:
        proc = subprocess.Popen(cmdline, shell=True,
166
                                creationflags=qtlib.openflags,
167
168
169
170
171
                                stderr=subprocess.PIPE,
                                stdout=subprocess.PIPE,
                                stdin=subprocess.PIPE)
        if block:
            proc.communicate()
172
    except (OSError, EnvironmentError) as e:
173
        QMessageBox.warning(None,
174
175
176
177
178
179
180
                _('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:
181
        QMessageBox.warning(None,
182
183
184
185
186
187
188
189
190
191
192
193
                _('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)


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


def visualdiff(ui, repo, pats, opts):
213
    revs = opts.get('rev', [])
214
215
216
217
218
    change = opts.get('change')

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

244
    pats = scmutil.expandpats(pats)
245
    m = match.match(repo.root, b'', pats, None, None, b'relpath')
246
    n2 = ctx2.node()
247
248
249
250
251
252

    def _status(ctx):
        status = repo.status(ctx.node(), n2, m)
        return status.modified, status.added, status.removed

    mod_a, add_a, rem_a = pycompat.maplist(set, _status(ctx1a))
253
    if ctx1b:
254
        mod_b, add_b, rem_b = pycompat.maplist(set, _status(ctx1b))
255
        cpy = copies.mergecopies(repo, ctx1a, ctx1b, ctx1a.ancestor(ctx1b))[0]
256
    else:
257
        cpy = copies.pathcopies(ctx1a, ctx2)
258
259
260
261
        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:
262
        QMessageBox.information(None,
263
264
265
266
267
268
                _('No file changes'),
                _('There are no file changes to view'))
        return None

    detectedtools = hglib.difftools(repo.ui)
    if not detectedtools:
269
        QMessageBox.warning(None,
270
271
272
273
                _('No diff tool found'),
                _('No visual diff tools were detected'))
        return None

274
    preferred = besttool(repo.ui, detectedtools, opts.get('tool'))
275
276
277

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

289
    cto = list(cpy.keys())
290
    for path in MAR:
291
        if path in cto:
292
293
294
295
            hascopies = True
            break
    else:
        hascopies = False
296
    force = repo.ui.configbool(b'tortoisehg', b'forcevdiffwin')
297
    if len(toollist) > 1 or (hascopies and len(MAR) > 1) or force:
298
299
300
        usewin = True
    else:
        preferred = toollist.pop()
301
302
303
        dirdiff = repo.ui.configbool(b'merge-tools', preferred + b'.dirdiff')
        dir3diff = repo.ui.configbool(b'merge-tools', preferred + b'.dir3diff')
        usewin = repo.ui.configbool(b'merge-tools', preferred + b'.usewin')
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
        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
332
    def dodiff():
333
334
        assert not (hascopies and len(MAR) > 1), \
                'dodiff cannot handle copies when diffing dirs'
335

336
337
338
339
340
341
        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
342
        dirs, labels, fns_and_mtimes = snapshotset(repo, ctxs, sa, sb, cpy,
343
344
345
346
347
348
                                                   copyworkingdir)
        dir1a, dir1b, dir2 = dirs
        label1a, label1b, label2 = labels
        fns_and_mtime = fns_and_mtimes[2]

        if len(MAR) > 1 and label2 == '':
349
350
351
            label2 = 'working files'

        def getfile(fname, dir, label):
kiilerix's avatar
kiilerix committed
352
            label = hglib.fromunicode(label)
Steve Borho's avatar
Steve Borho committed
353
            file = os.path.join(qtlib.gettempdir(), dir, fname)
354
355
            if os.path.isfile(file):
                return fname+label, file
356
            nullfile = os.path.join(qtlib.gettempdir(), b'empty')
kiilerix's avatar
kiilerix committed
357
            fp = open(nullfile, 'wb')
358
            fp.close()
359
360
            return (hglib.fromunicode(_nonexistant, 'replace') + label,
                    nullfile)
361
362
363
364

        # 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:
365
366
            file2 = MAR.pop()
            file2local = util.localpath(file2)
367
368
369
370
371
            if file2 in cto:
                file1 = util.localpath(cpy[file2])
            else:
                file1 = file2
            label1a, dir1a = getfile(file1, dir1a, label1a)
372
            if do3way:
373
                label1b, dir1b = getfile(file1, dir1b, label1b)
374
            label2, dir2 = getfile(file2local, dir2, label2)
375
376
377
378
379
        if do3way:
            label1a += '[local]'
            label1b += '[other]'
            label2 += '[merged]'

380
        repoagent = repo._pyqtobj  # TODO
381
382
383
        replace = dict(parent=dir1a, parent1=dir1a, parent2=dir1b,
                       plabel1=label1a, plabel2=label1b,
                       phash1=str(ctx1a), phash2=str(ctx1b),
384
                       repo=hglib.fromunicode(repoagent.displayName()),
385
386
387
388
389
                       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:
390
            try:
391
                if os.lstat(copy_fn).st_mtime != mtime:
392
393
394
395
396
                    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
397
398
399

    def dodiffwrapper():
        try:
Steve Borho's avatar
Steve Borho committed
400
            dodiff()
401
        finally:
Steve Borho's avatar
Steve Borho committed
402
            # cleanup happens atexit
403
            ui.note('cleaning up temp directory\n')
404
405
406
407
408
409
410
411
412
413

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

414
class FileSelectionDialog(QDialog):
415
416
417
    'Dialog for selecting visual diff candidates'
    def __init__(self, repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy):
        'Initialize the Dialog'
418
        QDialog.__init__(self)
419

420
        self.setWindowIcon(qtlib.geticon('visualdiff'))
421
422
423
424

        if ctx2.rev() is None:
            title = _('working changes')
        elif ctx1a == ctx2.parents()[0]:
425
            title = _('changeset %d:%s') % (ctx2.rev(), ctx2)
426
        else:
427
428
            title = _('revisions %d:%s to %d:%s') \
                % (ctx1a.rev(), ctx1a, ctx2.rev(), ctx2)
429
430
431
        title = _('Visual Diffs - ') + title
        if pats:
            title += _(' filtered')
432
        self.setWindowTitle(title)
433

434
        self.resize(650, 250)
435
436
        repoagent = repo._pyqtobj  # TODO
        self.reponame = hglib.fromunicode(repoagent.displayName())
437
438

        self.ctxs = (ctx1a, ctx1b, ctx2)
439
        self.filesets = (sa, sb)
440
        self.copies = cpy
441
442
        self.repo = repo
        self.curFile = None
443

444
        layout = QVBoxLayout()
445
446
        self.setLayout(layout)

447
448
        lbl = QLabel(_('Temporary files are removed when this dialog '
                       'is closed'))
449
450
        layout.addWidget(lbl)

451
        list = QListWidget()
452
453
        layout.addWidget(list)
        self.list = list
454
        list.itemActivated.connect(self.itemActivated)
455
456
457
458

        tools = hglib.difftools(repo.ui)
        preferred = besttool(repo.ui, tools)
        self.diffpath, self.diffopts, self.mergeopts = tools[preferred]
459
        self.tools = tools
460
        self.preferred = preferred
461
462

        if len(tools) > 1:
463
            hbox = QHBoxLayout()
464
465
            combo = QComboBox()
            lbl = QLabel(_('Select Tool:'))
466
467
468
469
            lbl.setBuddy(combo)
            hbox.addWidget(lbl)
            hbox.addWidget(combo, 1)
            layout.addLayout(hbox)
470
            for i, name in enumerate(tools.keys()):
471
472
473
474
475
                combo.addItem(name)
                if name == preferred:
                    defrow = i
            combo.setCurrentIndex(defrow)

476
            list.currentRowChanged.connect(self.updateToolSelection)
477
            combo.currentIndexChanged[str].connect(self.onToolSelected)
478
            self.toolCombo = combo
479

480
        BB = QDialogButtonBox
481
482
        bb = BB()
        layout.addWidget(bb)
483
484
485
486
487
488

        if ctx2.rev() is None:
            pass
            # Do not offer directory diffs when the working directory
            # is being referenced directly
        elif ctx1b:
489
490
491
492
493
494
            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)
495
        else:
496
497
            self.dbutton = bb.addButton(_('Directory diff'), BB.ActionRole)
            self.dbutton.pressed.connect(self.p1dirdiff)
498

499
        self.updateDiffButtons(preferred)
500

501
        QShortcut(QKeySequence('CTRL+D'), self.list, self.activateCurrent)
502
        QTimer.singleShot(0, self.fillmodel)
503

504
505
506
507
    @pyqtSlot()
    def fillmodel(self):
        repo = self.repo
        sa, sb = self.filesets
508
        self.dirs, self.revs = snapshotset(repo, self.ctxs, sa, sb, self.copies)[:2]
509
510
511
512
513
514
515
516
517
518

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

519
        mod_a, add_a, rem_a = sa
520
        for f in sorted(mod_a | add_a | rem_a):
521
            status = get_status(f, mod_a, add_a, rem_a)
522
            row = '%s %s' % (status, hglib.tounicode(f))
523
            self.list.addItem(row)
524

525
    @pyqtSlot(str)
526
    def onToolSelected(self, tool):
527
        'user selected a tool from the tool combo'
528
        tool = hglib.fromunicode(tool)
529
        assert tool in self.tools, tool
530
531
        self.diffpath, self.diffopts, self.mergeopts = self.tools[tool]
        self.updateDiffButtons(tool)
532

533
534
    @pyqtSlot(int)
    def updateToolSelection(self, row):
535
        'user selected a file, pick an appropriate tool from combo'
536
537
538
539
        if row == -1:
            return

        repo = self.repo
540
        patterns = repo.ui.configitems(b'diff-patterns')
541
        patterns = [(p, t) for p,t in patterns if t in self.tools]
542

543
544
545
546
547
        fname = self.list.item(row).text()[2:]
        fname = hglib.fromunicode(fname)
        if self.curFile == fname:
            return
        self.curFile = fname
548
        for pat, tool in patterns:
549
            mf = match.match(repo.root, b'', [pat])
550
551
552
553
            if mf(fname):
                selected = tool
                break
        else:
554
            selected = self.preferred
555
        for i, name in enumerate(self.tools.keys()):
556
            if name == selected:
557
                self.toolCombo.setCurrentIndex(i)
558

559
560
561
562
563
564
    def activateCurrent(self):
        'CTRL+D has been pressed'
        row = self.list.currentRow()
        if row >= 0:
            self.launch(self.list.item(row).text()[2:])

565
566
567
    def itemActivated(self, item):
        'A QListWidgetItem has been activated'
        self.launch(item.text()[2:])
568

569
    def updateDiffButtons(self, tool):
570
        # hg>=4.4: configbool() may return None as the default is set to None
571
        if hasattr(self, 'p1button'):
572
573
            d2 = self.repo.ui.configbool(b'merge-tools', tool + b'.dirdiff')
            d3 = self.repo.ui.configbool(b'merge-tools', tool + b'.dir3diff')
574
575
576
            self.p1button.setEnabled(bool(d2))
            self.p2button.setEnabled(bool(d2))
            self.p3button.setEnabled(bool(d3))
577
        elif hasattr(self, 'dbutton'):
578
            d2 = self.repo.ui.configbool(b'merge-tools', tool + b'.dirdiff')
579
            self.dbutton.setEnabled(bool(d2))
580

581
582
    def launch(self, fname):
        fname = hglib.fromunicode(fname)
583
584
585
586
587
588
589
590
591
592
593
594
595
596
        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:
597
                nullfile = os.path.join(qtlib.gettempdir(), b'empty')
598
599
                fp = open(nullfile, 'w')
                fp.close()
600
                return hglib.fromunicode(_nonexistant, 'replace'), nullfile
601
602
603
604
605

        local, file1a = getfile(ctx1a, dir1a, fname, source)
        if ctx1b:
            other, file1b = getfile(ctx1b, dir1b, fname, source)
        else:
Steve Borho's avatar
Steve Borho committed
606
            other, file1b = fname, None
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
        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)

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

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

648
    def threewaydirdiff(self):
649
650
651
652
653
654
655
656
657
        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)