|
# 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.
import gtk
import gobject
import os
import subprocess
import stat
import shutil
import threading
import tempfile
import re
from mercurial import hg, ui, cmdutil, util, error, match, copies
from tortoisehg.util.i18n import _
from tortoisehg.util import hglib, settings, paths
from tortoisehg.hgtk import gdialog, gtklib
try:
import win32con
openflags = win32con.CREATE_NO_WINDOW
except ImportError:
openflags = 0
# 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]')
def snapshotset(repo, ctxs, sa, sb, copies, tmproot, 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
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, tmproot)
label1a = '@%d' % ctx1a.rev()
# 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, tmproot)
label1b = '@%d' % ctx1b.rev()
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, tmproot)
else:
dir2 = repo.root
fns_mtime2 = []
# If ctx2 is working copy, use empty label.
label2 = ''
else:
dir2, fns_mtime2 = snapshot(repo, files2, ctx2, tmproot)
label2 = '@%d' % ctx2.rev()
dirs = [dir1a, dir1b, dir2]
labels = [label1a, label1b, label2]
fns_and_mtimes = [fns_mtime1a, fns_mtime1b, fns_mtime2]
return dirs, labels, fns_and_mtimes
def snapshot(repo, files, ctx, tmproot):
'''snapshot files as of some revision'''
dirname = os.path.basename(repo.root) or 'root'
if ctx.rev() is not None:
dirname = '%s.%s' % (dirname, str(ctx))
base = os.path.join(tmproot, dirname)
os.mkdir(base)
fns_and_mtime = []
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)
destdir = os.path.dirname(dest)
if not os.path.isdir(destdir):
os.makedirs(destdir)
data = repo.wwritedata(wfn, ctx[wfn].data())
f = open(dest, 'wb')
f.write(data)
f.close()
if ctx.rev() is None:
fns_and_mtime.append((dest, repo.wjoin(fn), os.path.getmtime(dest)))
elif os.name != 'nt':
# Make file read/only, to indicate it's static (archival) nature
os.chmod(dest, stat.S_IREAD)
return base, fns_and_mtime
def launchtool(cmd, opts, replace, block):
def quote(match):
key = match.group()[1:]
return util.shellquote(replace[key])
args = ' '.join(opts)
args = re.sub(_regex, quote, args)
cmdline = util.shellquote(cmd) + ' ' + args
cmdline = util.quotecommand(cmdline)
try:
proc = subprocess.Popen(cmdline, shell=True,
creationflags=openflags,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE)
if block:
proc.communicate()
except (OSError, EnvironmentError), e:
gdialog.Prompt(_('Tool launch failure'),
_('%s : %s') % (cmd, str(e)), None).run()
def filemerge(ui, fname, patchedfname):
'Launch the preferred visual diff tool for two text files'
detectedtools = hglib.difftools(ui)
if not detectedtools:
gdialog.Prompt(_('No diff tool found'),
_('No visual diff tools were detected'), None).run()
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)
def besttool(ui, tools):
'Select preferred or highest priority tool from dictionary'
preferred = ui.config('tortoisehg', 'vdiff') or ui.config('ui', 'merge')
if preferred and preferred in tools:
return preferred
pris = []
for t in tools.keys():
p = int(ui.config('merge-tools', t + '.priority', 0))
pris.append((-p, t))
tools = sorted(pris)
return tools[0][1]
def visualdiff(ui, repo, pats, opts):
revs = opts.get('rev')
change = opts.get('change')
try:
ctx1b = None
if change:
ctx2 = repo[change]
p = ctx2.parents()
if len(p) > 1:
ctx1a, ctx1b = p
else:
ctx1a = p[0]
else:
n1, n2 = cmdutil.revpair(repo, revs)
ctx1a, ctx2 = repo[n1], repo[n2]
p = ctx2.parents()
if not revs and len(p) > 1:
ctx1b = p[1]
except (error.LookupError, error.RepoError):
gdialog.Prompt(_('Unable to find changeset'),
_('You likely need to refresh this application'),
None).run()
return None
pats = cmdutil.expandpats(pats)
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])
cpy = copies.copies(repo, ctx1a, ctx1b, ctx1a.ancestor(ctx1b))[0]
else:
cpy = copies.copies(repo, ctx1a, ctx2, repo[-1])[0]
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:
gdialog.Prompt(_('No file changes'),
_('There are no file changes to view'), None).run()
return None
detectedtools = hglib.difftools(repo.ui)
if not detectedtools:
gdialog.Prompt(_('No diff tool found'),
_('No visual diff tools were detected'), None).run()
return None
preferred = besttool(repo.ui, detectedtools)
# 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:
if path in cto:
hascopies = True
break
else:
hascopies = False
force = repo.ui.configbool('tortoisehg', 'forcevdiffwin')
if len(toollist) > 1 or (hascopies and len(MAR) > 1) or force:
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
def dodiff(tmproot):
assert not (hascopies and len(MAR) > 1), \
'dodiff cannot handle copies when diffing dirs'
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
dirs, labels, fns_and_mtimes = snapshotset(repo, ctxs, sa, sb, cpy,
tmproot, copyworkingdir)
dir1a, dir1b, dir2 = dirs
label1a, label1b, label2 = labels
fns_and_mtime = fns_and_mtimes[2]
if len(MAR) > 1 and label2 == '':
label2 = 'working files'
def getfile(fname, dir, label):
file = os.path.join(tmproot, dir, fname)
if os.path.isfile(file):
return fname+label, file
nullfile = os.path.join(tmproot, 'empty')
fp = open(nullfile, 'w')
fp.close()
return _nonexistant+label, nullfile
# 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:
file2 = util.localpath(MAR.pop())
if file2 in cto:
file1 = util.localpath(cpy[file2])
else:
file1 = file2
label1a, dir1a = getfile(file1, dir1a, label1a)
if do3way:
label1b, dir1b = getfile(file1, dir1b, label1b)
label2, dir2 = getfile(file2, dir2, label2)
if do3way:
label1a += '[local]'
label1b += '[other]'
label2 += '[merged]'
replace = dict(parent=dir1a, parent1=dir1a, parent2=dir1b,
plabel1=label1a, plabel2=label1b,
phash1=str(ctx1a), phash2=str(ctx1b),
repo=hglib.get_reponame(repo),
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:
if os.path.getmtime(copy_fn) != mtime:
ui.debug('file changed while diffing. '
'Overwriting: %s (src: %s)\n' % (working_fn, copy_fn))
util.copyfile(copy_fn, working_fn)
def dodiffwrapper():
try:
dodiff(tmproot)
finally:
ui.note(_('cleaning up temp directory\n'))
try:
shutil.rmtree(tmproot)
except (IOError, OSError), e:
# Leaking temporary files, fix your diff tool config
ui.note(_('unable to clean temp directory: %s\n'), str(e))
tmproot = tempfile.mkdtemp(prefix='visualdiff.')
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()
class FileSelectionDialog(gtk.Dialog):
'Dialog for selecting visual diff candidates'
def __init__(self, repo, pats, ctx1a, sa, ctx1b, sb, ctx2, cpy):
'Initialize the Dialog'
gtk.Dialog.__init__(self, title=_('Visual Diffs'))
gtklib.set_tortoise_icon(self, 'menushowchanged.ico')
gtklib.set_tortoise_keys(self)
if ctx2.rev() is None:
title = _('working changes')
elif ctx1a == ctx2.parents()[0]:
title = _('changeset ') + str(ctx2.rev())
else:
title = _('revisions %d to %d') % (ctx1a.rev(), ctx2.rev())
title = _('Visual Diffs - ') + title
if pats:
title += _(' filtered')
self.set_title(title)
self.set_default_size(400, 250)
self.set_has_separator(False)
self.reponame=hglib.get_reponame(repo)
self.ctxs = (ctx1a, ctx1b, ctx2)
self.copies = cpy
self.ui = repo.ui
lbl = gtk.Label(_('Temporary files are removed when this dialog '
'is closed'))
self.vbox.pack_start(lbl, False, False, 2)
scroller = gtk.ScrolledWindow()
scroller.set_shadow_type(gtk.SHADOW_IN)
scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
treeview = gtk.TreeView()
self.treeview = treeview
treeview.get_selection().set_mode(gtk.SELECTION_SINGLE)
treeview.set_search_equal_func(self.search_filelist)
scroller.add(treeview)
self.vbox.pack_start(scroller, True, True, 2)
treeview.connect('row-activated', self.rowactivated)
treeview.set_headers_visible(False)
treeview.set_property('enable-grid-lines', True)
treeview.set_enable_search(False)
accelgroup = gtk.AccelGroup()
self.add_accel_group(accelgroup)
mod = gtklib.get_thg_modifier()
key, modifier = gtk.accelerator_parse(mod+'d')
treeview.add_accelerator('thg-diff', accelgroup, key,
modifier, gtk.ACCEL_VISIBLE)
treeview.connect('thg-diff', self.rowactivated)
cell = gtk.CellRendererText()
stcol = gtk.TreeViewColumn('Status', cell)
stcol.set_resizable(True)
stcol.add_attribute(cell, 'text', 0)
treeview.append_column(stcol)
cell = gtk.CellRendererText()
fcol = gtk.TreeViewColumn('Filename', cell)
fcol.set_resizable(True)
fcol.add_attribute(cell, 'text', 1)
treeview.append_column(fcol)
model = gtk.ListStore(str, str)
treeview.set_model(model)
tools = hglib.difftools(repo.ui)
preferred = besttool(repo.ui, tools)
self.diffpath, self.diffopts, self.mergeopts = tools[preferred]
hbox = gtk.HBox()
self.vbox.pack_start(hbox, False, False, 2)
if ctx2.rev() is None:
pass
# Do not offer directory diffs when the working directory
# is being referenced directly
elif ctx1b:
self.p1button = gtk.Button(_('Dir diff to p1'))
self.p1button.connect('pressed', self.p1dirdiff)
self.p2button = gtk.Button(_('Dir diff to p2'))
self.p2button.connect('pressed', self.p2dirdiff)
self.p3button = gtk.Button(_('3-way dir diff'))
self.p3button.connect('pressed', self.threewaydirdiff)
hbox.pack_end(self.p3button, False, False)
hbox.pack_end(self.p2button, False, False)
hbox.pack_end(self.p1button, False, False)
else:
self.dbutton = gtk.Button(_('Directory diff'))
self.dbutton.connect('pressed', self.p1dirdiff)
hbox.pack_end(self.dbutton, False, False)
self.update_diff_buttons(preferred)
if len(tools) > 1:
combo = gtk.combo_box_new_text()
for i, name in enumerate(tools.iterkeys()):
combo.append_text(name)
if name == preferred:
defrow = i
combo.set_active(defrow)
combo.connect('changed', self.toolselect, tools)
hbox.pack_start(combo, False, False, 2)
patterns = repo.ui.configitems('diff-patterns')
patterns = [(p, t) for p,t in patterns if t in tools]
filesel = treeview.get_selection()
filesel.connect('changed', self.fileselect, repo, combo, tools,
patterns, preferred)
gobject.idle_add(self.fillmodel, repo, model, sa, sb)
def fillmodel(self, repo, model, sa, sb):
tmproot = tempfile.mkdtemp(prefix='visualdiff.')
self.tmproot = tmproot
self.dirs, self.revs = snapshotset(repo, self.ctxs, sa, sb, self.copies, tmproot)[:2]
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 ' '
mod_a, add_a, rem_a = sa
for f in sorted(mod_a | add_a | rem_a):
model.append([get_status(f, mod_a, add_a, rem_a), hglib.toutf(f)])
self.connect('response', self.response)
def search_filelist(self, model, column, key, iter):
'case insensitive filename search'
key = key.lower()
if key in model.get_value(iter, 1).lower():
return False
return True
def toolselect(self, combo, tools):
'user selected a tool from the tool combo'
sel = combo.get_active_text()
if sel in tools:
self.diffpath, self.diffopts, self.mergeopts = tools[sel]
self.update_diff_buttons(sel)
def update_diff_buttons(self, tool):
if hasattr(self, 'p1button'):
d2 = self.ui.configbool('merge-tools', tool + '.dirdiff')
d3 = self.ui.configbool('merge-tools', tool + '.dir3diff')
self.p1button.set_sensitive(d2)
self.p2button.set_sensitive(d2)
self.p3button.set_sensitive(d3)
elif hasattr(self, 'dbutton'):
d2 = self.ui.configbool('merge-tools', tool + '.dirdiff')
self.dbutton.set_sensitive(d2)
def fileselect(self, selection, repo, combo, tools, patterns, preferred):
'user selected a file, pick an appropriate tool from combo'
model, path = selection.get_selected()
if not path:
return
row = model[path]
fname = row[-1]
for pat, tool in patterns:
mf = match.match(repo.root, '', [pat])
if mf(fname):
selected = tool
break
else:
selected = preferred
for i, name in enumerate(tools.iterkeys()):
if name == selected:
combo.set_active(i)
def response(self, window, resp):
self.should_live()
def should_live(self):
while self.tmproot:
try:
shutil.rmtree(self.tmproot)
return False
except (IOError, OSError), e:
resp = gdialog.CustomPrompt(_('Unable to delete temp files'),
_('Close diff tools and try again, or quit to leak files?'),
self, (_('Try &Again'), _('&Quit')), 1).run()
if resp == 0:
continue
else:
return False
return False
def rowactivated(self, tree, *args):
selection = tree.get_selection()
if selection.count_selected_rows() != 1:
return False
model, paths = selection.get_selected_rows()
self.launch(*model[paths[0]])
def launch(self, st, fname):
fname = hglib.fromutf(fname)
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:
nullfile = os.path.join(self.tmproot, 'empty')
fp = open(nullfile, 'w')
fp.close()
return _nonexistant, nullfile
local, file1a = getfile(ctx1a, dir1a, fname, source)
if ctx1b:
other, file1b = getfile(ctx1b, dir1b, fname, source)
else:
other = fname
file1b = None
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)
def p1dirdiff(self, button):
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)
def p2dirdiff(self, button):
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)
def threewaydirdiff(self, button):
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)
def run(ui, *pats, **opts):
try:
path = opts.get('bundle') or paths.find_root()
repo = hg.repository(ui, path=path)
except error.RepoError:
ui.warn(_('No repository found here') + '\n')
return None
pats = hglib.canonpaths(pats)
if opts.get('canonpats'):
pats = list(pats) + opts['canonpats']
return visualdiff(ui, repo, pats, opts)
|
Loading...