1# visdiff.py - launch external visual diff tools
2#
3# Copyright 2009 Steve Borho <steve@borho.org>
4#
5# This software may be used and distributed according to the terms of the
6# GNU General Public License version 2, incorporated herein by reference.
7
8from __future__ import absolute_import
9
10import os
11import re
12import stat
13import subprocess
14import threading
15
16from .qtcore import (
17    QTimer,
18    pyqtSlot,
19)
20from .qtgui import (
21    QComboBox,
22    QDialog,
23    QDialogButtonBox,
24    QHBoxLayout,
25    QKeySequence,
26    QLabel,
27    QListWidget,
28    QMessageBox,
29    QShortcut,
30    QVBoxLayout,
31)
32
33from mercurial import (
34    copies,
35    error,
36    match,
37    pycompat,
38    scmutil,
39    util,
40)
41from mercurial.utils import (
42    procutil,
43    stringutil,
44)
45
46from ..util import hglib
47from ..util.i18n import _
48from . import qtlib
49
50if hglib.TYPE_CHECKING:
51    from typing import (
52        Any,
53        Dict,
54        Iterable,
55        List,
56        Optional,
57        Sequence,
58        Set,
59        Text,
60        Tuple,
61        Union,
62    )
63    from mercurial import (
64        localrepo,
65        ui as uimod,
66    )
67    from .qtgui import (
68        QListWidgetItem,
69    )
70    from ..util.typelib import (
71        DiffTools,
72        HgContext,
73    )
74
75    # Destination name, source name, dest modification time
76    FnsAndMtime = Tuple[bytes, bytes, float]
77
78
79# Match parent2 first, so 'parent1?' will match both parent1 and parent
80_regex = b'\$(parent2|parent1?|child|plabel1|plabel2|clabel|repo|phash1|phash2|chash)'
81
82_nonexistant = _('[non-existant]')
83
84# This global counter is incremented for each visual diff done in a session
85# It ensures that the names for snapshots created do not collide.
86_diffCount = 0
87
88def snapshotset(repo, ctxs, sa, sb, copies, copyworkingdir = False):
89    # type: (localrepo.localrepository, Sequence[HgContext], List[Set[bytes]], List[Set[bytes]], Dict[bytes, bytes], bool) -> Tuple[List[Optional[bytes]], List[bytes], List[List[FnsAndMtime]]]
90    '''snapshot files from parent-child set of revisions'''
91    ctx1a, ctx1b, ctx2 = ctxs
92    mod_a, add_a, rem_a = sa
93    mod_b, add_b, rem_b = sb
94
95    global _diffCount
96    _diffCount += 1
97
98    if copies:
99        sources = set(copies.values())
100    else:
101        sources = set()
102
103    # Always make a copy of ctx1a
104    files1a = sources | mod_a | rem_a | ((mod_b | add_b) - add_a)
105    dir1a, fns_mtime1a = snapshot(repo, files1a, ctx1a)
106    label1a = b'@%d:%s' % (ctx1a.rev(), ctx1a)
107
108    # Make a copy of ctx1b if relevant
109    if ctx1b:
110        files1b = sources | mod_b | rem_b | ((mod_a | add_a) - add_b)
111        dir1b, fns_mtime1b = snapshot(repo, files1b, ctx1b)
112        label1b = b'@%d:%s' % (ctx1b.rev(), ctx1b)
113    else:
114        dir1b = None
115        fns_mtime1b = []  # type: List[FnsAndMtime]
116        label1b = b''
117
118    # Either make a copy of ctx2, or use working dir directly if relevant.
119    files2 = mod_a | add_a | mod_b | add_b
120    if ctx2.rev() is None:
121        if copyworkingdir:
122            dir2, fns_mtime2 = snapshot(repo, files2, ctx2)
123        else:
124            dir2 = repo.root
125            fns_mtime2 = []  # type: List[FnsAndMtime]
126        # If ctx2 is working copy, use empty label.
127        label2 = b''
128    else:
129        dir2, fns_mtime2 = snapshot(repo, files2, ctx2)
130        label2 = b'@%d:%s' % (ctx2.rev(), ctx2)
131
132    dirs = [dir1a, dir1b, dir2]
133    labels = [label1a, label1b, label2]
134    fns_and_mtimes = [fns_mtime1a, fns_mtime1b, fns_mtime2]
135    return dirs, labels, fns_and_mtimes
136
137def snapshot(repo, files, ctx):
138    # type: (localrepo.localrepository, Iterable[bytes], HgContext) -> Tuple[bytes, List[FnsAndMtime]]
139    '''snapshot repo files as of some revision, returning a tuple with the
140    created temporary snapshot dir and tuples of file info if using working
141    copy.'''
142    dirname = os.path.basename(repo.root) or b'root'
143    dirname += b'.%d' % _diffCount
144    if ctx.rev() is not None:
145        dirname += b'.%d' % ctx.rev()
146    base = os.path.join(qtlib.gettempdir(), dirname)
147    fns_and_mtime = []
148    if not os.path.exists(base):
149        os.makedirs(base)
150    for fn in files:
151        assert isinstance(fn, bytes), repr(fn)
152        wfn = util.pconvert(fn)
153        if wfn not in ctx:
154            # File doesn't exist; could be a bogus modify
155            continue
156        dest = os.path.join(base, wfn)
157        if os.path.exists(dest):
158            # File has already been snapshot
159            continue
160        destdir = os.path.dirname(dest)
161        try:
162            if not os.path.isdir(destdir):
163                os.makedirs(destdir)
164            fctx = ctx[wfn]
165            data = repo.wwritedata(wfn, fctx.data())
166            with open(dest, 'wb') as f:
167                f.write(data)
168            if b'x' in fctx.flags():
169                util.setflags(dest, False, True)
170            if ctx.rev() is None:
171                fns_and_mtime.append((dest, repo.wjoin(fn),
172                                    os.lstat(dest).st_mtime))
173            else:
174                # Make file read/only, to indicate it's static (archival) nature
175                os.chmod(dest, stat.S_IREAD)
176        except EnvironmentError:
177            pass
178    return base, fns_and_mtime
179
180def launchtool(cmd, opts, replace, block):
181    # type: (bytes, Sequence[bytes], Dict[Text, Union[bytes, Text]], bool) -> None
182    # TODO: fix up the bytes vs str in the replacement mapping
183    def quote(match):
184        key = pycompat.sysstr(match.group()[1:])
185        return procutil.shellquote(replace[key])
186
187    args = b' '.join(opts)
188    args = re.sub(_regex, quote, args)
189    cmdline = procutil.shellquote(cmd) + b' ' + args
190    try:
191        proc = subprocess.Popen(procutil.tonativestr(cmdline), shell=True,
192                                creationflags=qtlib.openflags,
193                                stderr=subprocess.PIPE,
194                                stdout=subprocess.PIPE,
195                                stdin=subprocess.PIPE)
196        if block:
197            proc.communicate()
198    except (OSError, EnvironmentError) as e:
199        QMessageBox.warning(None,
200                _('Tool launch failure'),
201                _('%s : %s') % (hglib.tounicode(cmd), hglib.tounicode(str(e))))
202
203def filemerge(ui, fname, patchedfname):
204    # type: (uimod.ui, Text, Text) -> None
205    'Launch the preferred visual diff tool for two text files'
206    detectedtools = hglib.difftools(ui)
207    if not detectedtools:
208        QMessageBox.warning(None,
209                _('No diff tool found'),
210                _('No visual diff tools were detected'))
211        return None
212    preferred = besttool(ui, detectedtools)
213    diffcmd, diffopts, mergeopts = detectedtools[preferred]
214    replace = dict(parent=fname, parent1=fname,
215                   plabel1=fname + _('[working copy]'),
216                   repo='', phash1='', phash2='', chash='',
217                   child=patchedfname, clabel=_('[original]'))
218    launchtool(diffcmd, diffopts, replace, True)
219
220
221def besttool(ui, tools, force=None):
222    # type: (uimod.ui, DiffTools, Optional[bytes]) -> bytes
223    'Select preferred or highest priority tool from dictionary'
224    preferred = force or ui.config(b'tortoisehg', b'vdiff') or \
225                         ui.config(b'ui', b'merge')
226    if preferred and preferred in tools:
227        return preferred
228    pris = []
229    for t in tools.keys():
230        try:
231            p = ui.configint(b'merge-tools', t + b'.priority')
232        except error.ConfigError as inst:
233            ui.warn(b'visdiff: %s\n' % stringutil.forcebytestr(inst))
234            p = 0
235        assert p is not None  # help pytype: default *.priority is 0
236        pris.append((-p, t))
237
238    return sorted(pris)[0][1]
239
240
241def visualdiff(ui, repo, pats, opts):
242    # type: (uimod.ui, localrepo.localrepository, Sequence[bytes], Dict[Text, Any]) -> Optional[FileSelectionDialog]
243    revs = opts.get('rev', [])
244    change = opts.get('change')
245
246    try:
247        ctx1b = None
248        if change:
249            # TODO: figure out what's the expect type
250            if isinstance(change, pycompat.unicode):
251                change = hglib.fromunicode(change)
252            if isinstance(change, bytes):
253                ctx2 = hglib.revsymbol(repo, change)
254            else:
255                ctx2 = repo[change]
256            p = ctx2.parents()
257            if len(p) > 1:
258                ctx1a, ctx1b = p
259            else:
260                ctx1a = p[0]
261        else:
262            n1, n2 = scmutil.revpair(repo, [hglib.fromunicode(rev)
263                                            for rev in revs])
264            ctx1a, ctx2 = repo[n1], repo[n2]
265            p = ctx2.parents()
266            if not revs and len(p) > 1:
267                ctx1b = p[1]
268    except (error.LookupError, error.RepoError):
269        QMessageBox.warning(None,
270                       _('Unable to find changeset'),
271                       _('You likely need to refresh this application'))
272        return None
273
274    return visual_diff(ui, repo, pats, ctx1a, ctx1b, ctx2, opts.get('tool'),
275                       opts.get('mainapp'), revs)
276
277def visual_diff(ui, repo, pats, ctx1a, ctx1b, ctx2, tool, mainapp=False,
278                revs=None):
279    # type: (uimod.ui, localrepo.localrepository, Sequence[bytes], HgContext, Optional[HgContext], HgContext, bytes, bool, Optional[Sequence[int]]) -> Optional[FileSelectionDialog]
280    """Opens the visual diff tool on the given file patterns in the given
281    contexts.  If a ``tool`` is provided, it is used, otherwise the diff tool
282    launched is determined by the configuration.  For a 2-way diff, ``ctx1a`` is
283    the context for the first revision, ``ctxb1`` is None, and ``ctx2`` is the
284    context for the second revision.  For a 3-way diff, ``ctx2`` is the wdir
285    context and ``ctx1a`` and ``ctx1b`` are the "local" and "other" contexts
286    respectively.
287    """
288    # TODO: Figure out how to get rid of the `revs` argument
289    if revs is None:
290        revs = []
291    pats = scmutil.expandpats(pats)
292    m = match.match(repo.root, b'', pats, None, None, b'relpath', ctx=ctx2)
293    n2 = ctx2.node()
294
295    def _status(ctx):
296        # type: (HgContext) -> Tuple[List[bytes], List[bytes], List[bytes]]
297        status = repo.status(ctx.node(), n2, m)
298        return status.modified, status.added, status.removed
299
300    mod_a, add_a, rem_a = pycompat.maplist(set, _status(ctx1a))
301    if ctx1b:
302        mod_b, add_b, rem_b = pycompat.maplist(set, _status(ctx1b))
303        cpy = copies.mergecopies(repo, ctx1a, ctx1b, ctx1a.ancestor(ctx1b))[0].copy
304    else:
305        cpy = copies.pathcopies(ctx1a, ctx2)
306        mod_b, add_b, rem_b = set(), set(), set()
307
308    cpy = {
309        dst: src for dst, src in cpy.items() if m(src) or m(dst)
310    }
311
312    MA = mod_a | add_a | mod_b | add_b
313    MAR = MA | rem_a | rem_b
314    if not MAR:
315        QMessageBox.information(None,
316                _('No file changes'),
317                _('There are no file changes to view'))
318        return None
319
320    detectedtools = hglib.difftools(repo.ui)
321    if not detectedtools:
322        QMessageBox.warning(None,
323                _('No diff tool found'),
324                _('No visual diff tools were detected'))
325        return None
326
327    preferred = besttool(repo.ui, detectedtools, tool)
328
329    # Build tool list based on diff-patterns matches
330    toollist = set()
331    patterns = repo.ui.configitems(b'diff-patterns')
332    patterns = [(p, t) for p,t in patterns if t in detectedtools]
333    for path in MAR:
334        for pat, tool in patterns:
335            mf = match.match(repo.root, b'', [pat])
336            if mf(path):
337                toollist.add(tool)
338                break
339        else:
340            toollist.add(preferred)
341
342    cto = list(cpy.keys())
343    for path in MAR:
344        if path in cto:
345            hascopies = True
346            break
347    else:
348        hascopies = False
349    force = repo.ui.configbool(b'tortoisehg', b'forcevdiffwin')
350    if len(toollist) > 1 or (hascopies and len(MAR) > 1) or force:
351        usewin = True
352    else:
353        preferred = toollist.pop()
354        dirdiff = repo.ui.configbool(b'merge-tools', preferred + b'.dirdiff')
355        dir3diff = repo.ui.configbool(b'merge-tools', preferred + b'.dir3diff')
356        usewin = repo.ui.configbool(b'merge-tools', preferred + b'.usewin')
357        if not usewin and len(MAR) > 1:
358            if ctx1b is not None:
359                usewin = not dir3diff
360            else:
361                usewin = not dirdiff
362    if usewin:
363        # Multiple required tools, or tool does not support directory diffs
364        sa = [mod_a, add_a, rem_a]
365        sb = [mod_b, add_b, rem_b]
366        dlg = FileSelectionDialog(repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy)
367        return dlg
368
369    # We can directly use the selected tool, without a visual diff window
370    diffcmd, diffopts, mergeopts = detectedtools[preferred]
371
372    # Disable 3-way merge if there is only one parent or no tool support
373    do3way = False
374    if ctx1b:
375        if mergeopts:
376            do3way = True
377            args = mergeopts
378        else:
379            args = diffopts
380            if str(ctx1b.rev()) in revs:
381                ctx1a = ctx1b
382    else:
383        args = diffopts
384
385    def dodiff():
386        assert not (hascopies and len(MAR) > 1), \
387                'dodiff cannot handle copies when diffing dirs'
388
389        sa = [mod_a, add_a, rem_a]
390        sb = [mod_b, add_b, rem_b]
391        ctxs = [ctx1a, ctx1b, ctx2]
392
393        # If more than one file, diff on working dir copy.
394        copyworkingdir = len(MAR) > 1
395        dirs, labels, fns_and_mtimes = snapshotset(repo, ctxs, sa, sb, cpy,
396                                                   copyworkingdir)
397        dir1a, dir1b, dir2 = dirs
398        label1a, label1b, label2 = labels
399        fns_and_mtime = fns_and_mtimes[2]
400
401        if len(MAR) > 1 and label2 == b'':
402            label2 = b'working files'
403
404        def getfile(fname, dir, label):
405            # type: (bytes, bytes, bytes) -> Tuple[bytes, bytes]
406            file = os.path.join(qtlib.gettempdir(), dir, fname)
407            if os.path.isfile(file):
408                return fname+label, file
409            nullfile = os.path.join(qtlib.gettempdir(), b'empty')
410            fp = open(nullfile, 'wb')
411            fp.close()
412            return (hglib.fromunicode(_nonexistant, 'replace') + label,
413                    nullfile)
414
415        # If only one change, diff the files instead of the directories
416        # Handle bogus modifies correctly by checking if the files exist
417        if len(MAR) == 1:
418            file2 = MAR.pop()
419            file2local = util.localpath(file2)
420            if file2 in cto:
421                file1 = util.localpath(cpy[file2])
422            else:
423                file1 = file2
424            label1a, dir1a = getfile(file1, dir1a, label1a)
425            if do3way:
426                label1b, dir1b = getfile(file1, dir1b, label1b)
427            label2, dir2 = getfile(file2local, dir2, label2)
428        if do3way:
429            label1a += b'[local]'
430            label1b += b'[other]'
431            label2 += b'[merged]'
432
433        repoagent = repo._pyqtobj  # TODO
434
435        # TODO: sort out bytes vs str
436        replace = dict(parent=dir1a, parent1=dir1a, parent2=dir1b,
437                       plabel1=label1a, plabel2=label1b,
438                       phash1=str(ctx1a), phash2=str(ctx1b),
439                       repo=hglib.fromunicode(repoagent.displayName()),
440                       clabel=label2, child=dir2, chash=str(ctx2))  # type: Dict[Text, Union[bytes, Text]]
441        launchtool(diffcmd, args, replace, True)
442
443        # detect if changes were made to mirrored working files
444        for copy_fn, working_fn, mtime in fns_and_mtime:
445            try:
446                if os.lstat(copy_fn).st_mtime != mtime:
447                    ui.debug(b'file changed while diffing. '
448                             b'Overwriting: %s (src: %s)\n'
449                             % (working_fn, copy_fn))
450                    util.copyfile(copy_fn, working_fn)
451            except EnvironmentError:
452                pass # Ignore I/O errors or missing files
453
454    if mainapp:
455        dodiff()
456    else:
457        # We are not the main application, so this must be done in a
458        # background thread
459        thread = threading.Thread(target=dodiff, name='visualdiff')
460        thread.setDaemon(True)
461        thread.start()
462
463class FileSelectionDialog(QDialog):
464    'Dialog for selecting visual diff candidates'
465    def __init__(self, repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy):
466        # type: (localrepo.localrepository, Sequence[bytes], HgContext, List[Set[bytes]], Optional[HgContext], List[Set[bytes]], HgContext, Dict[bytes, bytes]) -> None
467        'Initialize the Dialog'
468        QDialog.__init__(self)
469
470        self.setWindowIcon(qtlib.geticon('visualdiff'))
471
472        if ctx2.rev() is None:
473            title = _('working changes')
474        elif ctx1a == ctx2.parents()[0]:
475            title = _('changeset %d:%s') % (ctx2.rev(), ctx2)
476        else:
477            title = _('revisions %d:%s to %d:%s') \
478                % (ctx1a.rev(), ctx1a, ctx2.rev(), ctx2)
479        title = _('Visual Diffs - ') + title
480        if pats:
481            title += _(' filtered')
482        self.setWindowTitle(title)
483
484        self.resize(650, 250)
485        repoagent = repo._pyqtobj  # TODO
486        self.reponame = hglib.fromunicode(repoagent.displayName())
487
488        self.ctxs = (ctx1a, ctx1b, ctx2)
489        self.filesets = (sa, sb)
490        self.copies = cpy
491        self.repo = repo
492        self.curFile = None  # type: Optional[bytes]
493
494        layout = QVBoxLayout()
495        self.setLayout(layout)
496
497        lbl = QLabel(_('Temporary files are removed when this dialog '
498                       'is closed'))
499        layout.addWidget(lbl)
500
501        list = QListWidget()
502        layout.addWidget(list)
503        self.list = list
504        list.itemActivated.connect(self.itemActivated)
505
506        tools = hglib.difftools(repo.ui)
507        preferred = besttool(repo.ui, tools)
508        self.diffpath, self.diffopts, self.mergeopts = tools[preferred]
509        self.tools = tools
510        self.preferred = preferred
511
512        if len(tools) > 1:
513            hbox = QHBoxLayout()
514            combo = QComboBox()
515            lbl = QLabel(_('Select Tool:'))
516            lbl.setBuddy(combo)
517            hbox.addWidget(lbl)
518            hbox.addWidget(combo, 1)
519            layout.addLayout(hbox)
520            for i, name in enumerate(tools.keys()):
521                combo.addItem(hglib.tounicode(name))
522                if name == preferred:
523                    defrow = i
524            combo.setCurrentIndex(defrow)
525
526            list.currentRowChanged.connect(self.updateToolSelection)
527            combo.currentIndexChanged[str].connect(self.onToolSelected)
528            self.toolCombo = combo
529
530        BB = QDialogButtonBox
531        bb = BB()
532        layout.addWidget(bb)
533
534        if ctx2.rev() is None:
535            pass
536            # Do not offer directory diffs when the working directory
537            # is being referenced directly
538        elif ctx1b:
539            self.p1button = bb.addButton(_('Dir diff to p1'), BB.ActionRole)
540            self.p1button.pressed.connect(self.p1dirdiff)
541            self.p2button = bb.addButton(_('Dir diff to p2'), BB.ActionRole)
542            self.p2button.pressed.connect(self.p2dirdiff)
543            self.p3button = bb.addButton(_('3-way dir diff'), BB.ActionRole)
544            self.p3button.pressed.connect(self.threewaydirdiff)
545        else:
546            self.dbutton = bb.addButton(_('Directory diff'), BB.ActionRole)
547            self.dbutton.pressed.connect(self.p1dirdiff)
548
549        self.updateDiffButtons(preferred)
550
551        QShortcut(QKeySequence('CTRL+D'), self.list, self.activateCurrent)
552        QTimer.singleShot(0, self.fillmodel)
553
554    @pyqtSlot()
555    def fillmodel(self):
556        # type: () -> None
557        repo = self.repo
558        sa, sb = self.filesets
559        self.dirs, self.revs = snapshotset(repo, self.ctxs, sa, sb, self.copies)[:2]
560
561        def get_status(file, mod, add, rem):
562            # type: (bytes, Set[bytes], Set[bytes], Set[bytes]) -> Text
563            if file in mod:
564                return 'M'
565            if file in add:
566                return 'A'
567            if file in rem:
568                return 'R'
569            return ' '
570
571        mod_a, add_a, rem_a = sa
572        for f in sorted(mod_a | add_a | rem_a):
573            status = get_status(f, mod_a, add_a, rem_a)
574            row = '%s %s' % (status, hglib.tounicode(f))
575            self.list.addItem(row)
576
577    @pyqtSlot(str)
578    def onToolSelected(self, tool):
579        # type: (Text) -> None
580        'user selected a tool from the tool combo'
581        tool = hglib.fromunicode(tool)  # pytype: disable=annotation-type-mismatch
582        assert tool in self.tools, tool
583        self.diffpath, self.diffopts, self.mergeopts = self.tools[tool]
584        self.updateDiffButtons(tool)
585
586    @pyqtSlot(int)
587    def updateToolSelection(self, row):
588        # type: (int) -> None
589        'user selected a file, pick an appropriate tool from combo'
590        if row == -1:
591            return
592
593        repo = self.repo
594        patterns = repo.ui.configitems(b'diff-patterns')
595        patterns = [(p, t) for p,t in patterns if t in self.tools]
596
597        fname = self.list.item(row).text()[2:]
598        fname = hglib.fromunicode(fname)
599        if self.curFile == fname:
600            return
601        self.curFile = fname
602        for pat, tool in patterns:
603            mf = match.match(repo.root, b'', [pat])
604            if mf(fname):
605                selected = tool
606                break
607        else:
608            selected = self.preferred
609        for i, name in enumerate(self.tools.keys()):
610            if name == selected:
611                self.toolCombo.setCurrentIndex(i)
612
613    def activateCurrent(self):
614        # type: () -> None
615        'CTRL+D has been pressed'
616        row = self.list.currentRow()
617        if row >= 0:
618            self.launch(self.list.item(row).text()[2:])
619
620    def itemActivated(self, item):
621        # type: (QListWidgetItem) -> None
622        'A QListWidgetItem has been activated'
623        self.launch(item.text()[2:])
624
625    def updateDiffButtons(self, tool):
626        # type: (bytes) -> None
627        # hg>=4.4: configbool() may return None as the default is set to None
628        if hasattr(self, 'p1button'):
629            d2 = self.repo.ui.configbool(b'merge-tools', tool + b'.dirdiff')
630            d3 = self.repo.ui.configbool(b'merge-tools', tool + b'.dir3diff')
631            self.p1button.setEnabled(bool(d2))
632            self.p2button.setEnabled(bool(d2))
633            self.p3button.setEnabled(bool(d3))
634        elif hasattr(self, 'dbutton'):
635            d2 = self.repo.ui.configbool(b'merge-tools', tool + b'.dirdiff')
636            self.dbutton.setEnabled(bool(d2))
637
638    def launch(self, fname):
639        # type: (Text) -> None
640        fname = hglib.fromunicode(fname)  # pytype: disable=annotation-type-mismatch
641        source = self.copies.get(fname, None)
642        dir1a, dir1b, dir2 = self.dirs
643        rev1a, rev1b, rev2 = self.revs
644        ctx1a, ctx1b, ctx2 = self.ctxs
645
646        # pytype: disable=redundant-function-type-comment
647        def getfile(ctx, dir, fname, source):
648            # type: (HgContext, bytes, bytes, Optional[bytes]) -> Tuple[bytes, bytes]
649            m = ctx.manifest()
650            if fname in m:
651                path = os.path.join(dir, util.localpath(fname))
652                return fname, path
653            elif source and source in m:
654                path = os.path.join(dir, util.localpath(source))
655                return source, path
656            else:
657                nullfile = os.path.join(qtlib.gettempdir(), b'empty')
658                fp = open(nullfile, 'w')
659                fp.close()
660                return hglib.fromunicode(_nonexistant, 'replace'), nullfile
661        # pytype: enable=redundant-function-type-comment
662
663        local, file1a = getfile(ctx1a, dir1a, fname, source)
664        if ctx1b:
665            other, file1b = getfile(ctx1b, dir1b, fname, source)
666        else:
667            other, file1b = fname, None
668        fname, file2 = getfile(ctx2, dir2, fname, None)  # pytype: disable=annotation-type-mismatch
669
670        label1a = local+rev1a
671        label1b = other+rev1b
672        label2 = fname+rev2
673        if ctx1b:
674            label1a += b'[local]'
675            label1b += b'[other]'
676            label2 += b'[merged]'
677
678        # Function to quote file/dir names in the argument string
679        replace = dict(parent=file1a, parent1=file1a, plabel1=label1a,
680                       parent2=file1b, plabel2=label1b,
681                       repo=self.reponame,
682                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
683                       clabel=label2, child=file2)  # type: Dict[Text, Union[bytes, Text]]
684        args = ctx1b and self.mergeopts or self.diffopts
685        launchtool(self.diffpath, args, replace, False)
686
687    def p1dirdiff(self):
688        # type: () -> None
689        dir1a, dir1b, dir2 = self.dirs
690        rev1a, rev1b, rev2 = self.revs
691        ctx1a, ctx1b, ctx2 = self.ctxs
692
693        replace = dict(parent=dir1a, parent1=dir1a, plabel1=rev1a,
694                       repo=self.reponame,
695                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
696                       parent2='', plabel2='', clabel=rev2, child=dir2)  # type: Dict[Text, Union[bytes, Text]]
697        launchtool(self.diffpath, self.diffopts, replace, False)
698
699    def p2dirdiff(self):
700        # type: () -> None
701        dir1a, dir1b, dir2 = self.dirs
702        rev1a, rev1b, rev2 = self.revs
703        ctx1a, ctx1b, ctx2 = self.ctxs
704
705        replace = dict(parent=dir1b, parent1=dir1b, plabel1=rev1b,
706                       repo=self.reponame,
707                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
708                       parent2='', plabel2='', clabel=rev2, child=dir2)  # type: Dict[Text, Union[bytes, Text]]
709        launchtool(self.diffpath, self.diffopts, replace, False)
710
711    def threewaydirdiff(self):
712        # type: () -> None
713        dir1a, dir1b, dir2 = self.dirs
714        rev1a, rev1b, rev2 = self.revs
715        ctx1a, ctx1b, ctx2 = self.ctxs
716
717        replace = dict(parent=dir1a, parent1=dir1a, plabel1=rev1a,
718                       repo=self.reponame,
719                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
720                       parent2=dir1b, plabel2=rev1b, clabel=dir2, child=rev2)  # type: Dict[Text, Union[bytes, Text]]
721        launchtool(self.diffpath, self.mergeopts, replace, False)
722