PageRenderTime 258ms CodeModel.GetById 205ms app.highlight 46ms RepoModel.GetById 1ms app.codeStats 1ms

/tortoisehg/hgtk/visdiff.py

https://bitbucket.org/tortoisehg/hgtk/
Python | 638 lines | 615 code | 12 blank | 11 comment | 13 complexity | b3520572d15c9380f6b8024b8c4100ab MD5 | raw file
  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
  8import gtk
  9import gobject
 10import os
 11import subprocess
 12import stat
 13import shutil
 14import threading
 15import tempfile
 16import re
 17
 18from mercurial import hg, ui, cmdutil, util, error, match, copies
 19
 20from tortoisehg.util.i18n import _
 21from tortoisehg.util import hglib, settings, paths
 22
 23from tortoisehg.hgtk import gdialog, gtklib
 24
 25try:
 26    import win32con
 27    openflags = win32con.CREATE_NO_WINDOW
 28except ImportError:
 29    openflags = 0
 30
 31# Match parent2 first, so 'parent1?' will match both parent1 and parent
 32_regex = '\$(parent2|parent1?|child|plabel1|plabel2|clabel|repo|phash1|phash2|chash)'
 33
 34_nonexistant = _('[non-existant]')
 35
 36def snapshotset(repo, ctxs, sa, sb, copies, tmproot, copyworkingdir = False):
 37    '''snapshot files from parent-child set of revisions'''
 38    ctx1a, ctx1b, ctx2 = ctxs
 39    mod_a, add_a, rem_a = sa
 40    mod_b, add_b, rem_b = sb
 41    if copies:
 42        sources = set(copies.values())
 43    else:
 44        sources = set()
 45
 46    # Always make a copy of ctx1a
 47    files1a = sources | mod_a | rem_a | ((mod_b | add_b) - add_a)
 48    dir1a, fns_mtime1a = snapshot(repo, files1a, ctx1a, tmproot)
 49    label1a = '@%d' % ctx1a.rev()
 50
 51    # Make a copy of ctx1b if relevant
 52    if ctx1b:
 53        files1b = sources | mod_b | rem_b | ((mod_a | add_a) - add_b)
 54        dir1b, fns_mtime1b = snapshot(repo, files1b, ctx1b, tmproot)
 55        label1b = '@%d' % ctx1b.rev()
 56    else:
 57        dir1b = None
 58        fns_mtime1b = []
 59        label1b = ''
 60
 61    # Either make a copy of ctx2, or use working dir directly if relevant.
 62    files2 = mod_a | add_a | mod_b | add_b
 63    if ctx2.rev() is None:
 64        if copyworkingdir:
 65            dir2, fns_mtime2 = snapshot(repo, files2, ctx2, tmproot)
 66        else:
 67            dir2 = repo.root
 68            fns_mtime2 = []
 69        # If ctx2 is working copy, use empty label.
 70        label2 = ''
 71    else:
 72        dir2, fns_mtime2 = snapshot(repo, files2, ctx2, tmproot)
 73        label2 = '@%d' % ctx2.rev()
 74
 75    dirs = [dir1a, dir1b, dir2]
 76    labels = [label1a, label1b, label2]
 77    fns_and_mtimes = [fns_mtime1a, fns_mtime1b, fns_mtime2]
 78    return dirs, labels, fns_and_mtimes
 79
 80def snapshot(repo, files, ctx, tmproot):
 81    '''snapshot files as of some revision'''
 82    dirname = os.path.basename(repo.root) or 'root'
 83    if ctx.rev() is not None:
 84        dirname = '%s.%s' % (dirname, str(ctx))
 85    base = os.path.join(tmproot, dirname)
 86    os.mkdir(base)
 87    fns_and_mtime = []
 88    for fn in files:
 89        wfn = util.pconvert(fn)
 90        if not wfn in ctx:
 91            # File doesn't exist; could be a bogus modify
 92            continue
 93        dest = os.path.join(base, wfn)
 94        destdir = os.path.dirname(dest)
 95        if not os.path.isdir(destdir):
 96            os.makedirs(destdir)
 97        data = repo.wwritedata(wfn, ctx[wfn].data())
 98        f = open(dest, 'wb')
 99        f.write(data)
100        f.close()
101        if ctx.rev() is None:
102            fns_and_mtime.append((dest, repo.wjoin(fn), os.path.getmtime(dest)))
103        elif os.name != 'nt':
104            # Make file read/only, to indicate it's static (archival) nature
105            os.chmod(dest, stat.S_IREAD)
106    return base, fns_and_mtime
107
108def launchtool(cmd, opts, replace, block):
109    def quote(match):
110        key = match.group()[1:]
111        return util.shellquote(replace[key])
112    args = ' '.join(opts)
113    args = re.sub(_regex, quote, args)
114    cmdline = util.shellquote(cmd) + ' ' + args
115    cmdline = util.quotecommand(cmdline)
116    try:
117        proc = subprocess.Popen(cmdline, shell=True,
118                                creationflags=openflags,
119                                stderr=subprocess.PIPE,
120                                stdout=subprocess.PIPE,
121                                stdin=subprocess.PIPE)
122        if block:
123            proc.communicate()
124    except (OSError, EnvironmentError), e:
125        gdialog.Prompt(_('Tool launch failure'),
126                       _('%s : %s') % (cmd, str(e)), None).run()
127
128def filemerge(ui, fname, patchedfname):
129    'Launch the preferred visual diff tool for two text files'
130    detectedtools = hglib.difftools(ui)
131    if not detectedtools:
132        gdialog.Prompt(_('No diff tool found'),
133                       _('No visual diff tools were detected'), None).run()
134        return None
135    preferred = besttool(ui, detectedtools)
136    diffcmd, diffopts, mergeopts = detectedtools[preferred]
137    replace = dict(parent=fname, parent1=fname,
138                   plabel1=fname + _('[working copy]'),
139                   repo='', phash1='', phash2='', chash='',
140                   child=patchedfname, clabel=_('[original]'))
141    launchtool(diffcmd, diffopts, replace, True)
142
143
144def besttool(ui, tools):
145    'Select preferred or highest priority tool from dictionary'
146    preferred = ui.config('tortoisehg', 'vdiff') or ui.config('ui', 'merge')
147    if preferred and preferred in tools:
148        return preferred
149    pris = []
150    for t in tools.keys():
151        p = int(ui.config('merge-tools', t + '.priority', 0))
152        pris.append((-p, t))
153    tools = sorted(pris)
154    return tools[0][1]
155
156
157def visualdiff(ui, repo, pats, opts):
158    revs = opts.get('rev')
159    change = opts.get('change')
160
161    try:
162        ctx1b = None
163        if change:
164            ctx2 = repo[change]
165            p = ctx2.parents()
166            if len(p) > 1:
167                ctx1a, ctx1b = p
168            else:
169                ctx1a = p[0]
170        else:
171            n1, n2 = cmdutil.revpair(repo, revs)
172            ctx1a, ctx2 = repo[n1], repo[n2]
173            p = ctx2.parents()
174            if not revs and len(p) > 1:
175                ctx1b = p[1]
176    except (error.LookupError, error.RepoError):
177        gdialog.Prompt(_('Unable to find changeset'),
178                       _('You likely need to refresh this application'),
179                       None).run()
180        return None
181
182    pats = cmdutil.expandpats(pats)
183    m = match.match(repo.root, '', pats, None, None, 'relpath')
184    n2 = ctx2.node()
185    mod_a, add_a, rem_a = map(set, repo.status(ctx1a.node(), n2, m)[:3])
186    if ctx1b:
187        mod_b, add_b, rem_b = map(set, repo.status(ctx1b.node(), n2, m)[:3])
188        cpy = copies.copies(repo, ctx1a, ctx1b, ctx1a.ancestor(ctx1b))[0]
189    else:
190        cpy = copies.copies(repo, ctx1a, ctx2, repo[-1])[0]
191        mod_b, add_b, rem_b = set(), set(), set()
192    MA = mod_a | add_a | mod_b | add_b
193    MAR = MA | rem_a | rem_b
194    if not MAR:
195        gdialog.Prompt(_('No file changes'),
196                       _('There are no file changes to view'), None).run()
197        return None
198
199    detectedtools = hglib.difftools(repo.ui)
200    if not detectedtools:
201        gdialog.Prompt(_('No diff tool found'),
202                       _('No visual diff tools were detected'), None).run()
203        return None
204
205    preferred = besttool(repo.ui, detectedtools)
206
207    # Build tool list based on diff-patterns matches
208    toollist = set()
209    patterns = repo.ui.configitems('diff-patterns')
210    patterns = [(p, t) for p,t in patterns if t in detectedtools]
211    for path in MAR:
212        for pat, tool in patterns:
213            mf = match.match(repo.root, '', [pat])
214            if mf(path):
215                toollist.add(tool)
216                break
217        else:
218            toollist.add(preferred)
219
220    cto = cpy.keys()
221    for path in MAR:
222        if path in cto:
223            hascopies = True
224            break
225    else:
226        hascopies = False
227    force = repo.ui.configbool('tortoisehg', 'forcevdiffwin')
228    if len(toollist) > 1 or (hascopies and len(MAR) > 1) or force:
229        usewin = True
230    else:
231        preferred = toollist.pop()
232        dirdiff = repo.ui.configbool('merge-tools', preferred + '.dirdiff')
233        dir3diff = repo.ui.configbool('merge-tools', preferred + '.dir3diff')
234        usewin = repo.ui.configbool('merge-tools', preferred + '.usewin')
235        if not usewin and len(MAR) > 1:
236            if ctx1b is not None:
237                usewin = not dir3diff
238            else:
239                usewin = not dirdiff
240    if usewin:
241        # Multiple required tools, or tool does not support directory diffs
242        sa = [mod_a, add_a, rem_a]
243        sb = [mod_b, add_b, rem_b]
244        dlg = FileSelectionDialog(repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy)
245        return dlg
246
247    # We can directly use the selected tool, without a visual diff window
248    diffcmd, diffopts, mergeopts = detectedtools[preferred]
249
250    # Disable 3-way merge if there is only one parent or no tool support
251    do3way = False
252    if ctx1b:
253        if mergeopts:
254            do3way = True
255            args = mergeopts
256        else:
257            args = diffopts
258            if str(ctx1b.rev()) in revs:
259                ctx1a = ctx1b
260    else:
261        args = diffopts
262
263    def dodiff(tmproot):
264        assert not (hascopies and len(MAR) > 1), \
265                'dodiff cannot handle copies when diffing dirs'
266
267        sa = [mod_a, add_a, rem_a]
268        sb = [mod_b, add_b, rem_b]
269        ctxs = [ctx1a, ctx1b, ctx2]
270
271        # If more than one file, diff on working dir copy.
272        copyworkingdir = len(MAR) > 1
273        dirs, labels, fns_and_mtimes = snapshotset(repo, ctxs, sa, sb, cpy, 
274                                                   tmproot, copyworkingdir)
275        dir1a, dir1b, dir2 = dirs
276        label1a, label1b, label2 = labels
277        fns_and_mtime = fns_and_mtimes[2]
278
279        if len(MAR) > 1 and label2 == '':
280            label2 = 'working files'
281
282        def getfile(fname, dir, label):
283            file = os.path.join(tmproot, dir, fname)
284            if os.path.isfile(file):
285                return fname+label, file
286            nullfile = os.path.join(tmproot, 'empty')
287            fp = open(nullfile, 'w')
288            fp.close()
289            return _nonexistant+label, nullfile
290
291        # If only one change, diff the files instead of the directories
292        # Handle bogus modifies correctly by checking if the files exist
293        if len(MAR) == 1:
294            file2 = util.localpath(MAR.pop())
295            if file2 in cto:
296                file1 = util.localpath(cpy[file2])
297            else:
298                file1 = file2
299            label1a, dir1a = getfile(file1, dir1a, label1a)
300            if do3way:
301                label1b, dir1b = getfile(file1, dir1b, label1b)
302            label2, dir2 = getfile(file2, dir2, label2)
303        if do3way:
304            label1a += '[local]'
305            label1b += '[other]'
306            label2 += '[merged]'
307
308        replace = dict(parent=dir1a, parent1=dir1a, parent2=dir1b,
309                       plabel1=label1a, plabel2=label1b,
310                       phash1=str(ctx1a), phash2=str(ctx1b),
311                       repo=hglib.get_reponame(repo),
312                       clabel=label2, child=dir2, chash=str(ctx2))
313        launchtool(diffcmd, args, replace, True)
314
315        # detect if changes were made to mirrored working files
316        for copy_fn, working_fn, mtime in fns_and_mtime:
317            if os.path.getmtime(copy_fn) != mtime:
318                ui.debug('file changed while diffing. '
319                         'Overwriting: %s (src: %s)\n' % (working_fn, copy_fn))
320                util.copyfile(copy_fn, working_fn)
321
322    def dodiffwrapper():
323        try:
324            dodiff(tmproot)
325        finally:
326            ui.note(_('cleaning up temp directory\n'))
327            try:
328                shutil.rmtree(tmproot)
329            except (IOError, OSError), e:
330                # Leaking temporary files, fix your diff tool config
331                ui.note(_('unable to clean temp directory: %s\n'), str(e))
332
333    tmproot = tempfile.mkdtemp(prefix='visualdiff.')
334    if opts.get('mainapp'):
335        dodiffwrapper()
336    else:
337        # We are not the main application, so this must be done in a
338        # background thread
339        thread = threading.Thread(target=dodiffwrapper, name='visualdiff')
340        thread.setDaemon(True)
341        thread.start()
342
343class FileSelectionDialog(gtk.Dialog):
344    'Dialog for selecting visual diff candidates'
345    def __init__(self, repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy):
346        'Initialize the Dialog'
347        gtk.Dialog.__init__(self, title=_('Visual Diffs'))
348        gtklib.set_tortoise_icon(self, 'menushowchanged.ico')
349        gtklib.set_tortoise_keys(self)
350
351        if ctx2.rev() is None:
352            title = _('working changes')
353        elif ctx1a == ctx2.parents()[0]:
354            title = _('changeset ') + str(ctx2.rev())
355        else:
356            title = _('revisions %d to %d') % (ctx1a.rev(), ctx2.rev())
357        title = _('Visual Diffs - ') + title
358        if pats:
359            title += _(' filtered')
360        self.set_title(title)
361
362        self.set_default_size(400, 250)
363        self.set_has_separator(False)
364        self.reponame=hglib.get_reponame(repo)
365
366        self.ctxs = (ctx1a, ctx1b, ctx2)
367        self.copies = cpy
368        self.ui = repo.ui
369
370        lbl = gtk.Label(_('Temporary files are removed when this dialog'
371            ' is closed'))
372        self.vbox.pack_start(lbl, False, False, 2)
373
374        scroller = gtk.ScrolledWindow()
375        scroller.set_shadow_type(gtk.SHADOW_IN)
376        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
377        treeview = gtk.TreeView()
378        self.treeview = treeview
379        treeview.get_selection().set_mode(gtk.SELECTION_SINGLE)
380        treeview.set_search_equal_func(self.search_filelist)
381        scroller.add(treeview)
382        self.vbox.pack_start(scroller, True, True, 2)
383
384        treeview.connect('row-activated', self.rowactivated)
385        treeview.set_headers_visible(False)
386        treeview.set_property('enable-grid-lines', True)
387        treeview.set_enable_search(False)
388
389        accelgroup = gtk.AccelGroup()
390        self.add_accel_group(accelgroup)
391        mod = gtklib.get_thg_modifier()
392        key, modifier = gtk.accelerator_parse(mod+'d')
393        treeview.add_accelerator('thg-diff', accelgroup, key,
394                        modifier, gtk.ACCEL_VISIBLE)
395        treeview.connect('thg-diff', self.rowactivated)
396
397        cell = gtk.CellRendererText()
398        stcol = gtk.TreeViewColumn('Status', cell)
399        stcol.set_resizable(True)
400        stcol.add_attribute(cell, 'text', 0)
401        treeview.append_column(stcol)
402
403        cell = gtk.CellRendererText()
404        fcol = gtk.TreeViewColumn('Filename', cell)
405        fcol.set_resizable(True)
406        fcol.add_attribute(cell, 'text', 1)
407        treeview.append_column(fcol)
408
409        model = gtk.ListStore(str, str)
410        treeview.set_model(model)
411
412        tools = hglib.difftools(repo.ui)
413        preferred = besttool(repo.ui, tools)
414        self.diffpath, self.diffopts, self.mergeopts = tools[preferred]
415
416        hbox = gtk.HBox()
417        self.vbox.pack_start(hbox, False, False, 2)
418
419        if ctx2.rev() is None:
420            pass
421            # Do not offer directory diffs when the working directory
422            # is being referenced directly
423        elif ctx1b:
424            self.p1button = gtk.Button(_('Dir diff to p1'))
425            self.p1button.connect('pressed', self.p1dirdiff)
426            self.p2button = gtk.Button(_('Dir diff to p2'))
427            self.p2button.connect('pressed', self.p2dirdiff)
428            self.p3button = gtk.Button(_('3-way dir diff'))
429            self.p3button.connect('pressed', self.threewaydirdiff)
430            hbox.pack_end(self.p3button, False, False)
431            hbox.pack_end(self.p2button, False, False)
432            hbox.pack_end(self.p1button, False, False)
433        else:
434            self.dbutton = gtk.Button(_('Directory diff'))
435            self.dbutton.connect('pressed', self.p1dirdiff)
436            hbox.pack_end(self.dbutton, False, False)
437
438        self.update_diff_buttons(preferred)
439
440        if len(tools) > 1:
441            combo = gtk.combo_box_new_text()
442            for i, name in enumerate(tools.iterkeys()):
443                combo.append_text(name)
444                if name == preferred:
445                    defrow = i
446            combo.set_active(defrow)
447            combo.connect('changed', self.toolselect, tools)
448            hbox.pack_start(combo, False, False, 2)
449
450            patterns = repo.ui.configitems('diff-patterns')
451            patterns = [(p, t) for p,t in patterns if t in tools]
452            filesel = treeview.get_selection()
453            filesel.connect('changed', self.fileselect, repo, combo, tools,
454                            patterns, preferred)
455
456        gobject.idle_add(self.fillmodel, repo, model, sa, sb)
457
458    def fillmodel(self, repo, model, sa, sb):
459        tmproot = tempfile.mkdtemp(prefix='visualdiff.')
460        self.tmproot = tmproot
461        self.dirs, self.revs = snapshotset(repo, self.ctxs, sa, sb, self.copies, tmproot)[:2]
462
463        def get_status(file, mod, add, rem):
464            if file in mod:
465                return 'M'
466            if file in add:
467                return 'A'
468            if file in rem:
469                return 'R'
470            return ' '
471
472        mod_a, add_a, rem_a = sa
473        for f in sorted(mod_a | add_a | rem_a):
474            model.append([get_status(f, mod_a, add_a, rem_a), hglib.toutf(f)])
475
476        self.connect('response', self.response)
477
478    def search_filelist(self, model, column, key, iter):
479        'case insensitive filename search'
480        key = key.lower()
481        if key in model.get_value(iter, 1).lower():
482            return False
483        return True
484
485    def toolselect(self, combo, tools):
486        'user selected a tool from the tool combo'
487        sel = combo.get_active_text()
488        if sel in tools:
489            self.diffpath, self.diffopts, self.mergeopts = tools[sel]
490            self.update_diff_buttons(sel)
491
492    def update_diff_buttons(self, tool):
493        if hasattr(self, 'p1button'):
494            d2 = self.ui.configbool('merge-tools', tool + '.dirdiff')
495            d3 = self.ui.configbool('merge-tools', tool + '.dir3diff')
496            self.p1button.set_sensitive(d2)
497            self.p2button.set_sensitive(d2)
498            self.p3button.set_sensitive(d3)
499        elif hasattr(self, 'dbutton'):
500            d2 = self.ui.configbool('merge-tools', tool + '.dirdiff')
501            self.dbutton.set_sensitive(d2)
502
503    def fileselect(self, selection, repo, combo, tools, patterns, preferred):
504        'user selected a file, pick an appropriate tool from combo'
505        model, path = selection.get_selected()
506        if not path:
507            return
508        row = model[path]
509        fname = row[-1]
510        for pat, tool in patterns:
511            mf = match.match(repo.root, '', [pat])
512            if mf(fname):
513                selected = tool
514                break
515        else:
516            selected = preferred
517        for i, name in enumerate(tools.iterkeys()):
518            if name == selected:
519                combo.set_active(i)
520
521    def response(self, window, resp):
522        self.should_live()
523
524    def should_live(self):
525        while self.tmproot:
526            try:
527                shutil.rmtree(self.tmproot)
528                return False
529            except (IOError, OSError), e:
530                resp = gdialog.CustomPrompt(_('Unable to delete temp files'),
531                    _('Close diff tools and try again, or quit to leak files?'),
532                    self, (_('Try &Again'), _('&Quit')), 1).run()
533                if resp == 0:
534                    continue
535                else:
536                    return False
537        return False
538
539    def rowactivated(self, tree, *args):
540        selection = tree.get_selection()
541        if selection.count_selected_rows() != 1:
542            return False
543        model, paths = selection.get_selected_rows()
544        self.launch(*model[paths[0]])
545
546    def launch(self, st, fname):
547        fname = hglib.fromutf(fname)
548        source = self.copies.get(fname, None)
549        dir1a, dir1b, dir2 = self.dirs
550        rev1a, rev1b, rev2 = self.revs
551        ctx1a, ctx1b, ctx2 = self.ctxs
552
553        def getfile(ctx, dir, fname, source):
554            m = ctx.manifest()
555            if fname in m:
556                path = os.path.join(dir, util.localpath(fname))
557                return fname, path
558            elif source and source in m:
559                path = os.path.join(dir, util.localpath(source))
560                return source, path
561            else:
562                nullfile = os.path.join(self.tmproot, 'empty')
563                fp = open(nullfile, 'w')
564                fp.close()
565                return _nonexistant, nullfile
566
567        local, file1a = getfile(ctx1a, dir1a, fname, source)
568        if ctx1b:
569            other, file1b = getfile(ctx1b, dir1b, fname, source)
570        else:
571            other = fname
572            file1b = None
573        fname, file2 = getfile(ctx2, dir2, fname, None)
574
575        label1a = local+rev1a
576        label1b = other+rev1b
577        label2 = fname+rev2
578        if ctx1b:
579            label1a += '[local]'
580            label1b += '[other]'
581            label2 += '[merged]'
582
583        # Function to quote file/dir names in the argument string
584        replace = dict(parent=file1a, parent1=file1a, plabel1=label1a,
585                       parent2=file1b, plabel2=label1b,
586                       repo=self.reponame,
587                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
588                       clabel=label2, child=file2)
589        args = ctx1b and self.mergeopts or self.diffopts
590        launchtool(self.diffpath, args, replace, False)
591
592    def p1dirdiff(self, button):
593        dir1a, dir1b, dir2 = self.dirs
594        rev1a, rev1b, rev2 = self.revs
595        ctx1a, ctx1b, ctx2 = self.ctxs
596
597        replace = dict(parent=dir1a, parent1=dir1a, plabel1=rev1a,
598                       repo=self.reponame,
599                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
600                       parent2='', plabel2='', clabel=rev2, child=dir2)
601        launchtool(self.diffpath, self.diffopts, replace, False)
602
603    def p2dirdiff(self, button):
604        dir1a, dir1b, dir2 = self.dirs
605        rev1a, rev1b, rev2 = self.revs
606        ctx1a, ctx1b, ctx2 = self.ctxs
607
608        replace = dict(parent=dir1b, parent1=dir1b, plabel1=rev1b,
609                       repo=self.reponame,
610                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
611                       parent2='', plabel2='', clabel=rev2, child=dir2)
612        launchtool(self.diffpath, self.diffopts, replace, False)
613
614    def threewaydirdiff(self, button):
615        dir1a, dir1b, dir2 = self.dirs
616        rev1a, rev1b, rev2 = self.revs
617        ctx1a, ctx1b, ctx2 = self.ctxs
618
619        replace = dict(parent=dir1a, parent1=dir1a, plabel1=rev1a,
620                       repo=self.reponame,
621                       phash1=str(ctx1a), phash2=str(ctx1b), chash=str(ctx2),
622                       parent2=dir1b, plabel2=rev1b, clabel=dir2, child=rev2)
623        launchtool(self.diffpath, self.mergeopts, replace, False)
624
625
626def run(ui, *pats, **opts):
627    try:
628        path = opts.get('bundle') or paths.find_root()
629        repo = hg.repository(ui, path=path)
630    except error.RepoError:
631        ui.warn(_('No repository found here') + '\n')
632        return None
633
634    pats = hglib.canonpaths(pats)
635    if opts.get('canonpats'):
636        pats = list(pats) + opts['canonpats']
637
638    return visualdiff(ui, repo, pats, opts)