[PATCH 1 of 5] add hgmerge subsystem
Steve Borho
steve at borho.org
Thu Jan 31 22:56:46 CST 2008
# HG changeset patch
# User Steve Borho <steve at borho.org>
# Date 1201840117 21600
# Node ID d97cd00ba47b21050f1c85ccd0e06bba36d80eee
# Parent 9f1e6ab76069641a5e3ff5b0831da0a3f83365cb
add hgmerge subsystem
diff --git a/mercurial/hgmerge/__init__.py b/mercurial/hgmerge/__init__.py
new file mode 100644
--- /dev/null
+++ b/mercurial/hgmerge/__init__.py
@@ -0,0 +1,461 @@
+# -*- coding: utf-8 -*-
+""" Logic for merging changes in two versions of a file.
+ at var stocktools: Sequence of stock plug-in representations.
+"""
+import shutil
+import StringIO
+import filecmp
+import random
+import traceback
+import os.path
+import re
+
+import mercurial.util as hgutil
+import _simplemerge
+import _plugins as hgmergeplugs
+from _plugins import toolplugin
+import _pluginapi as hgplugapi
+
+# Initially not defined
+plugins = None
+
+stocktools = hgmergeplugs.plugins
+
+def _eoltype(fctx):
+ ''' The EOL type of the file.
+ '''
+ if fctx.islink():
+ return 'symlink'
+ else:
+ return hgutil.eoltype(fctx.fname)
+
+class _optexception(Exception):
+ pass
+
+class _invalidoptval(_optexception):
+ ''' Invalid value for option.
+ '''
+ def __init__(self, option, val):
+ _optexception.__init__(self, 'Invalid value for %s: %s' % (option, val))
+ self.option, self.value = option, val
+
+class _missingopt(_optexception):
+ ''' A required option is missing.
+ '''
+ def __init__(self, option):
+ _optexception.__init__(self, 'Option %s is missing' % option)
+ self.option = option
+
+class _pluginopts(object):
+ ''' Plug-in options handler baseclass.
+ '''
+ def __init__(self, ui, sectname, name):
+ self.attrs = {}
+ self.__ui, self.__sect, self.__name = ui, sectname, name
+ self._set_int('priority')
+
+ def _set_string(self, prop, **kwds):
+ self._set_opt(prop, self.__ui.config, **kwds)
+
+ def _set_bool(self, prop, **kwds):
+ self._set_opt(prop, self.__ui.configbool, **kwds)
+
+ def _set_list(self, prop, **kwds):
+ self._set_opt(prop, self._configlist, **kwds)
+
+ def _set_int(self, prop, **kwds):
+ self._set_opt(prop, self._configint, **kwds)
+
+ def _set_opt(self, prop, getfunc, attr=None):
+ try:
+ val = getfunc(self.__sect, '%s.%s' % (self.__name, prop))
+ except ValueError:
+ if getfunc is self.__ui.config:
+ raise
+ # Conversion error
+ val = None
+ raise _invalidoptval(prop, val)
+ if val is None:
+ return
+
+ if attr is None:
+ attr = prop.replace('.', '_')
+ self.attrs[attr] = val
+
+ def _configint(self, sect, field):
+ val = self.__ui.config(sect, field)
+ if val is not None:
+ return int(val)
+
+ def _configlist(self, sect, field):
+ ''' Replace ui.configlist since it doesn't seem to allow elements with
+ whitespace.
+ '''
+ val = self.__ui.config(sect, field)
+ if val is not None:
+ import shlex
+ return shlex.split(val)
+
+class _toolopts(_pluginopts):
+ ''' Tool plug-in options handler.
+ '''
+ def __init__(self, ui, sectname, name):
+ _pluginopts.__init__(self, ui, sectname, name)
+
+ self._set_string('executable')
+ self._set_list('args')
+ self._set_string('winreg_key')
+ self._set_string('winreg_name')
+ self._set_string('winreg_append')
+ for prop in ('stdout', 'check_conflicts', 'symlink', 'binary'):
+ self._set_bool(prop)
+
+def _create_tool(name, opts, prevplug, ui):
+ ''' Create tool from user definition.
+ '''
+ def warn_invalid(reason):
+ ui.warn('invalid tool definition: %s (%s)\n' % (name, reason))
+
+ plug = toolplugin(name, **opts.attrs)
+ plug.userprops = opts.attrs.keys()
+ return plug
+
+def _modify_plug(plug, opts, ui):
+ plug.set_options(opts, ui)
+
+def _setup_plugs(ui):
+ def handle_conf(name, plugs):
+ for p in plugs:
+ if p.name.lower() == name.lower():
+ plug = p
+ break
+ else:
+ plug = None
+
+ try: opts = _toolopts(ui, sectname, name)
+ except _invalidoptval, err:
+ # Invalid value for a required option
+ ui.warn('couldn\'t use %s\'s configuration, bad value for %s: %s\n'
+ % (name, err.option, err.value))
+ return
+ except _missingopt, err:
+ ui.warn('couldn\'t use %s\'s configuration, missing option %s\n' %
+ (name, err.option))
+ return
+
+ # If this is a new plug-in, define a new plug-in, otherwise, apply to
+ # existing
+
+ if plug is None:
+ # Define new plug
+ newplug = _create_tool(name, opts, plug, ui)
+ if newplug is not None:
+ plugs.append(newplug)
+ else:
+ # Modify existing plug
+ _modify_plug(plug, opts, ui)
+
+ sectname = 'merge-tools'
+
+ # Read plug-in configurations in the order they are encountered
+
+ global plugins
+ plugins = list(hgmergeplugs.plugins)
+
+ plugconfs = []
+ for field, val in ui.configitems(sectname):
+ try: name = field.split('.', 1)[0]
+ except ValueError:
+ name = field
+ if name in plugconfs:
+ # This was already handled
+ continue
+ plugconfs.append(name)
+ handle_conf(name, plugins)
+
+ # Sort the plug-ins based on priority
+ def getkey(plug):
+ return plug.priority
+ plugins.sort(key=getkey, reverse=True)
+
+def _get_requested(repo, fname):
+ ''' Get plug-in requested by user, through available configuration
+ mechanisms.
+
+ None is returned if no plug-in is specified or it can't be detected. The
+ second return value indicates whether the user has chosen this plug-in
+ based on a file pattern.
+ @return: plug-in, patmatch (boolean).
+ '''
+ patmatch = False
+ requested = None
+
+ # check file patterns first
+ for key, cmd in repo.ui.configitems('merge'):
+ if key not in ('default', 'followcopies', 'followdirs'):
+ try:
+ mf = hgutil.matcher(repo.root, "", [key], [], [])[1]
+ if mf(fname):
+ requested = cmd
+ patmatch = True
+ except util.Abort, e:
+ # do not backtrace on bad match patterns
+ ui.warn(str(e) + '\n')
+
+ plug = None
+ if requested:
+ plug = requested
+ else:
+ # Fall back to generic plug-in
+ plug = repo.ui.config('merge', 'default')
+ if plug is not None:
+ requested = True
+
+ if not requested:
+ return (None, patmatch)
+
+ # Evaluate requested tools (see if they are available etc.)
+
+ if plug == 'takelocal':
+ plugin = hgmergeplugs.takelocal()
+ elif plug == 'takeother':
+ plugin = hgmergeplugs.takeother()
+ else:
+ for plugin in plugins:
+ if plugin.name == plug and plugin.detect(repo.ui):
+ break
+ else:
+ plugin = None
+ repo.ui.warn('could not find requested plug-in: %s\n' % plug)
+
+ return (plugin, patmatch)
+
+def get_plugin(repo, fname):
+ ''' Get interactive merge plug-in (alternative to simplemerge).
+
+ @param repo: Mercurial repository.
+ @param fname: Name of file being merged.
+ @return: Plug-in, requested. If the plug-in can't be detected, it will be
+ None. The I{requested} return value indicates whether the user requested a
+ specific plug-in.
+
+ @note: The user may, via configuration, specify the plug-in to use. If the
+ requested plug-in isn't found, None is returned. User specifications are
+ ordered like so: filetype (pattern match), default.
+ '''
+ ui = repo.ui
+
+ global plugins
+ if plugins is None:
+ _setup_plugs(ui)
+
+ inter, patmatch = _get_requested(repo, fname)
+ if patmatch or inter is not None:
+ # A special plug-in was requested
+ return (inter, patmatch)
+
+ for plug in plugins:
+ if plug.detect(ui):
+ ui.debug('auto-detected %s interactive merge tool\n' % plug.name)
+ inter = plug
+ break
+
+ return (inter, False)
+
+def query_plugins(ui, rescan=False):
+ ''' Get available plug-ins.
+
+ @param rescan: Force re-scanning of plug-in definitions?
+ '''
+ global plugins
+ if plugins is None or rescan:
+ _setup_plugs(ui)
+ plugs = []
+ for plug in plugins:
+ if plug.detect(ui):
+ plugs.append(plug)
+ return plugs
+
+def _is_mergeable(tool, patmatch, base, local, other):
+ ''' Are the files mergeable?
+ @return: Success?
+ '''
+ mergetypes = ['dos', 'unix', 'mac']
+
+ # Do not merge binary files unless the binary property has been set by
+ # the user, or it's unspecified and tool was selected by a pattern match
+ if tool is not None:
+ if tool._binary:
+ mergetypes.append('binary')
+ elif patmatch and 'binary' not in tool.useropts:
+ mergetypes.append('binary')
+ if tool._symlink:
+ mergetypes.append('symlink')
+
+ for fctx in (local, other, base):
+ if _eoltype(fctx) not in mergetypes:
+ return False
+
+ return True
+
+def _ask_unmergeable(ui, local, other, output):
+ ''' File is not mergeable, ask user which version to keep.
+ @return: Success?
+ '''
+ def _describe(fctx):
+ '''Describe a file to the user'''
+ eoltp = _eoltype(fctx)
+ if eoltp == 'symlink':
+ return 'is a symlink to ' + fctx.data()
+ elif eoltp in ('dos', 'unix', 'mac'):
+ return 'is a %s style text file: %s' % (eoltp, fctx.fname)
+ elif eoltp == 'binary':
+ return 'is a binary file: ' + fctx.fname
+ else:
+ return 'is an unknown file type: ' + fctx.fname
+
+ prompt = '\n%s cannot be merged. Choose version to keep.\n' % output.path()
+ prompt += 'local %s\n' % _describe(local)
+ prompt += 'other %s\n\n' % _describe(other)
+ prompt += '(k)eep local or take (o)ther?'
+ rslt = ui.prompt(prompt, '[ko]', 'k')
+ if rslt == 'o':
+ # Replace local file with 'other' file.
+ ui.debug('replacing with other version')
+ os.remove(local.fname)
+ if other.islink() and hasattr(os, 'symlink'):
+ ui.debug('symlinking to %s' % (other.data(),))
+ os.symlink(other.data(), local.fname)
+ else:
+ ui.debug('making copy')
+ shutil.copy2(other.fname, local.fname)
+
+ return True
+
+def merge(repo, local, base, other, pluginspec=None):
+ ''' Perform a three-way file merge.
+
+ We first try to merge the changes automatically, without user interaction,
+ using simplemerge. If there are merge conflicts however, we invoke a merge
+ plug-in automatically, which typically launches a graphical tool for the
+ user to interact with.
+
+ We have an ordered set of stock plug-ins to choose from, but the user can
+ assign these priority or configure them in other ways, or even add new
+ plug-ins. This configuration happens in hgrc.
+
+ Note that the file arguments must be passed as L{filectx} instances.
+ @param repo: Mercurial repository.
+ @param base: Base file version context.
+ @param local: Local file version context.
+ @param other: Other file version context.
+ @param pluginspec: Optionally specify plug-in to use. Same as returned by
+ get_plugin.
+ @return: 0 - success, 1 - conflict, 2 - error.
+ '''
+ def run_merge(plugin, backup):
+ ''' Run the specified merge plug-in. '''
+ ui = repo.ui
+ try:
+ try:
+ # Specifically treat False as error indication, None is OK
+ r = plugin.merge(*mergeargs)
+ if r is None:
+ # Accept None
+ r = 0
+ except Exception:
+ msg = traceback.format_exc()
+ ui.warn('caught exception in merge method:\n%s\n' % msg)
+ return 2
+ return r
+ finally:
+ pass
+
+ ui = repo.ui
+
+ if pluginspec is None:
+ pluginspec = get_plugin(repo, local.fname)
+ interactive, patmatch = pluginspec
+
+ if isinstance(interactive, hgmergeplugs.takelocal):
+ # Just keep local version
+ return 0
+
+ if patmatch and interactive is None:
+ # The user requested a certain plug-in for this filetype, so don't
+ # use simplemerge
+ return 2
+
+ if not _is_mergeable(interactive, patmatch, base, local, other):
+ if _ask_unmergeable(ui, local, other, local):
+ return 0
+ else:
+ return 2
+
+ # The original file is moved to a backup before merging, only once we've
+ # determined a successful merge is the backup removed
+
+ backup = '%s.orig.%d' % (local.fname, random.randint(0, 1000))
+ hgutil.rename(local.fname, backup)
+ # Create an empty output file with the exact same permissions as the
+ # original
+ file(local.fname, 'w').close()
+ shutil.copystat(backup, local.fname)
+ # Also make a copy for reference
+ changetest = '%s.chg.%d' % (local.fname, random.randint(0, 1000))
+ shutil.copy2(backup, changetest)
+ mergeargs = (backup, base.fname, other.fname, local.fname, ui)
+
+ try:
+ try:
+ r = 1 # For fall-back to interactive
+ onlysimple = True
+ if not patmatch:
+ # Try non-interactive merge
+ r = _simplemerge.simplemerge(quiet=True, *mergeargs)
+
+ if r == 1 and interactive is not None and (ui.configbool('merge',
+ 'resolve_conflicts', default=True) or patmatch):
+ # Fall back to interactive merge
+
+ # Make the output mirror the original local copy, in case
+ # its contents matter to the plug-in
+ shutil.copy2(backup, local.fname)
+ onlysimple = False
+ r = run_merge(interactive, backup)
+ except Exception:
+ r = 2
+
+ if r == 0 or (r == 1 and onlysimple):
+ # The backup isn't needed
+ ui.debug('removing backup\n')
+ try: os.remove(backup)
+ except EnvironmentError: pass
+
+ if r != 0:
+ if r not in (1, 2):
+ ui.debug('got unexpected return value: %r\n' % (r,))
+ r = 2
+
+ if not onlysimple or r != 1:
+ ui.warn('merging failed (error code %s), the original file has '
+ 'been backed up to %s\n' % (r, backup))
+ elif onlysimple:
+ ui.warn('merging finished with conflicts\n')
+ return r
+
+ # Compare local against changetest
+ if filecmp.cmp(local.fname, changetest):
+ ui.write('%s seems unchanged\n' % local.fname)
+ if repo.ui.prompt("was the merge successful? [y/n]",
+ "[yn]", 'n') == 'y':
+ return 0
+ else:
+ return 1
+ finally:
+ # Cleanup
+
+ try: os.remove(changetest)
+ except EnvironmentError: pass
+
+ return r
diff --git a/mercurial/hgmerge/_pluginapi.py b/mercurial/hgmerge/_pluginapi.py
new file mode 100644
--- /dev/null
+++ b/mercurial/hgmerge/_pluginapi.py
@@ -0,0 +1,68 @@
+''' API for merge plug-ins.
+'''
+import re
+import os.path
+
+import mercurial.util as hgutil
+
+def checkconflicts(file):
+ '''Look for conflict markers. Return True if conflicts are found'''
+ conflicts = re.compile("^(<<<<<<< .*|=======|>>>>>>> .*)$", re.M)
+ f = open(file)
+ try:
+ for l in f:
+ if conflicts.match(l):
+ return True
+ finally: f.close()
+ return False
+
+def runcommand(arguments, ui, stdout=None):
+ ''' Run command in separate process.
+ @return: Process exit code.
+ '''
+ cmdline = [hgutil.shellquote(arg) for arg in arguments] + ['<',
+ hgutil.nulldev]
+ cmdline = ' '.join(cmdline)
+ ui.debug(cmdline, '\n')
+ fp = hgutil.popen(cmdline)
+ # Read the output regardless of whether we need it, since otherwise we might
+ # have a broken pipe
+ for l in fp:
+ if stdout is not None:
+ stdout.write(l)
+ ret = fp.close()
+ if ret is None:
+ # None indicates no error
+ return 0
+ return ret
+
+def _changefileeol(file, old, new):
+ '''Convert all EOL markers in a text file.
+ '''
+ data = open(file, "rb").read()
+ newdata = re.sub(old, new, data)
+ if newdata != data:
+ f = open(file, "wb")
+ f.write(newdata)
+ f.close()
+
+def cleanupeol(base, output, ui):
+ ''' Revert EOL format to base format after merge.
+
+ Some merge tools have problems with implicit EOL conversion
+ during the merge. Those tools' run wrappers should call this
+ function to fixup the output EOL format back to the base format
+ after the merge.
+ '''
+ basetype = hgutil.eoltype(base)
+ outputtype = hgutil.eoltype(output)
+ if basetype == outputtype:
+ return
+ eol = { 'dos': '\r\n', 'unix': '\n', 'mac': '\r' }
+ try:
+ _changefileeol(output, eol[outputtype], eol[basetype])
+ ui.status("fixup line format of %s (%s->%s)" % (output,
+ outputtype, basetype))
+ except KeyError:
+ ui.warn("unable to fixup %s (%s->%s)" % (output,
+ outputtype, basetype))
diff --git a/mercurial/hgmerge/_plugins.py b/mercurial/hgmerge/_plugins.py
new file mode 100644
--- /dev/null
+++ b/mercurial/hgmerge/_plugins.py
@@ -0,0 +1,265 @@
+''' Plug-in classes. '''
+import os.path
+import shutil
+import re
+
+import mercurial.hgmerge._pluginapi as hgplugapi
+import mercurial.util as hgutil
+
+class _plugin(object):
+ ''' The baseclass of all plug-ins.
+
+ @ivar name: Name of plug-in.
+ @ivar priority: Plug-in priority.
+ '''
+ def __init__(self, name, priority=0):
+ self.name, self.priority = name, priority
+
+ def set_options(self, options, ui):
+ ''' Set options.
+ '''
+ for attr, val in options.attrs.items():
+ setattr(self, attr, val)
+
+class toolplugin(_plugin):
+ ''' The baseclass of plug-ins which execute a tool.
+
+ Common to tool plug-ins is that they have a certain executable and a list
+ of arguments. The arguments may contain variables, that are expanded before
+ the tool is executed. This class allows for configuring of the executable
+ and its arguments.
+
+ The executable may initially be specified as either None, just a tool name,
+ or a pathname. After calling premerge, it will be defined as the tool's
+ absolute pathname. The argument list may also be modified in premerge.
+
+ The Supported Variables
+ =======================
+ $base, $local, $other, $output
+
+ @ivar executable: The pathname of the tool's executable.
+ @ivar args: The argument list to the tool, may contain variables. Default
+ $output, $base', $other.
+ '''
+ def __init__(self, name, executable=None, args=None, stdout=False,
+ check_conflicts=False, winreg_key=None, winreg_name=None,
+ winreg_append=None, symlink=False, binary=False, priority=0):
+ ''' Constructor.
+ @param name: Specify plug-in name.
+ @param executable: Specify the tool executable.
+ @param args: Optionally specify the tool arguments.
+ @param stdout: Direct standard out to output file?
+ @param check_conflicts: Check for standard conflict markers after merge?
+ @param binary: Can this merge tool handle binary files?
+ @param symlink: Can this merge tool handle symlinks? (unlikely)
+ @param winreg_key: Windows registry key with application path
+ @param winreg_name: name of value to read from registry key
+ @param winreg_append: string to append to value read from key
+ '''
+ _plugin.__init__(self, name, priority=priority)
+
+ if executable is None:
+ executable = name.lower() # Sensible default
+ if args is None:
+ args = ['$output', '$base', '$other']
+ else:
+ args = list(args)
+
+ (self.executable, self.args) = (executable, args)
+ (self._stdout, self._check_conflicts) = (stdout, check_conflicts)
+ (self._winreg_key, self._winreg_name,
+ self._winreg_append) = (winreg_key, winreg_name, winreg_append)
+ (self._symlink, self._binary) = (symlink, binary)
+
+ def detect(self, ui):
+ ''' Attempt to detect tool.
+
+ This default implementation follows this strategy:
+ 1. Try registry.
+ 2. Try shell path.
+ @param ui: Mercurial UI.
+ @return: Success?
+ '''
+ for func in (self._detect_in_reg, self._detect_in_path):
+ path = func()
+ if path is not None:
+ self._finalize(path)
+ return True
+ return False
+
+ def merge(self, local, base, other, output, ui):
+ ''' Perform merge.
+
+ In this default implementation, the result from the process is returned
+ directly.
+ @param base: Base file.
+ @param local: Local file.
+ @param other: Other file.
+ @param output: File for merge output.
+ @param ui: Mercurial UI.
+ @return: 0 - success, 1 - failure (conflicts), else error
+
+ @note: Only call this after detect.
+ '''
+ substtbl = {'base': base, 'local': local, 'other': other, 'output':
+ output}
+ args = []
+ for arg in self.args:
+ m = re.match(r'\$([^$]+)', arg)
+ if m:
+ # A variable, substitute it
+ key = m.group(1)
+ if key in substtbl:
+ args.append(substtbl[key])
+ else:
+ # A literal
+ args.append(arg)
+
+ if self._stdout:
+ outfile = file(output, 'w')
+ else:
+ outfile = None
+ r = hgplugapi.runcommand([self.executable] + args, ui, stdout=outfile)
+ if r == 0 and self._check_conflicts:
+ # Verify that there are no conflicts
+ if hgplugapi.checkconflicts(output):
+ ui.status('conflict markers detected in %s' % output)
+ return 1
+
+ return r
+
+ def postmerge(self, local, base, other, output, ui):
+ ''' Invoke after merging.
+
+ Default implementation: If requested, fix EOL in output.
+ @param base: Base file.
+ @param local: Local file.
+ @param other: Other file.
+ @param output: File for merge output.
+ @param ui: Mercurial UI.
+ @return: None.
+ '''
+ hgplugapi.cleanupeol(base, output, ui)
+
+ def _detect_in_path(self):
+ ''' Try to detect executable in shell path.
+
+ The executable is searched for along the shell's path. If it is found,
+ _finalize is called.
+ @return: Found path.
+ '''
+ return hgutil.find_exe(self.executable)
+
+ def _detect_in_reg(self):
+ ''' Try to detect installation in (Windows) registry.
+ @return: Found path (may be None).
+ '''
+ try: from mercurial.util_win32 import lookup_reg
+ except ImportError: return None
+
+ if self._winreg_key:
+ path = lookup_reg(self._winreg_key, self._winreg_name)
+ if path is not None:
+ path += self._winreg_append or ''
+ if os.access(path, os.X_OK):
+ return path
+ return None
+
+ def _finalize(self, executable, args=None):
+ ''' Finalize executable and args.
+ '''
+ if args is None:
+ args = self.args
+ self.executable, self.args = executable, args
+
+class _customplugin(toolplugin):
+ ''' Baseclass for all plug-ins implemented directly in Python.
+ '''
+ def __init__(self, name=None, priority=0):
+ if name is None:
+ name = self.__class__.__name__
+ _plugin.__init__(self, name, priority=priority)
+
+ def detect(self, ui):
+ return True
+
+# Plug-in definitions start here
+
+class takelocal(_customplugin):
+ ''' Ignore changes from other heads.
+ '''
+ def __init__(self):
+ _customplugin.__init__(self, 'takelocal')
+
+ def merge(self, local, base, other, output, ui):
+ ui.status('Taking local file, ignoring remote changes\n')
+ shutil.copy2(local, output)
+ return 0
+
+class takeother(_customplugin):
+ '''Ignore local changes.
+ '''
+ def __init__(self):
+ _customplugin.__init__(self, 'takeother')
+
+ def merge(self, local, base, other, output, ui):
+ ui.status('Taking other file, ignoring local changes\n')
+ shutil.copy2(other, output)
+ return 0
+
+class guiffy(toolplugin):
+ def __init__(self):
+ toolplugin.__init__(self, 'guiffy',
+ args=['-s', '-eauto', '-h1mine', '-h2theirs', '$local', '$other',
+ '$base', '$output'])
+
+ def merge(self, local, base, other, output, ui):
+ # Guiffy complains when output file already exists, so we remove it.
+ os.remove(output)
+ return toolplugin.merge(self, local, base, other, output, ui)
+
+class kdiff3(toolplugin):
+ def __init__(self):
+ toolplugin.__init__(self, 'kdiff3', args=['--auto', "--L1", "Base",
+ "--L2", "Local", "--L3", "Other", '$base', '$local', '$other',
+ '-o', '$output'], winreg_key='Software\\KDiff3',
+ winreg_append='\\kdiff3.exe')
+
+ def merge(self, local, base, other, output, ui):
+ ''' Implement in order to clean up after kdiff3. '''
+ r = toolplugin.merge(self, local, base, other, output, ui)
+ try: os.remove('%s.orig' % output)
+ except EnvironmentError: pass
+ return r
+
+plugins = (
+ kdiff3(),
+ toolplugin('gvimdiff', args=['--nofork', '-d', '-g', '-O', '$output',
+ '$other', '$base'], winreg_key='Software\\Vim\\GVim',
+ winreg_name='path'),
+ toolplugin('merge', args=['$output', '$base', '$other'],
+ check_conflicts=True),
+ toolplugin('gpyfm', args=['$output', '$base', '$other']),
+ toolplugin('meld', args=['$local', '$output', '$other']),
+ toolplugin('tkdiff', args=['$local', '$other', '-a', '$base', '-o',
+ '$output']),
+ toolplugin('xxdiff', args=['--show-merged-pane',
+ '--exit-with-merge-status', '--title1', 'mine', '--title2', 'ancestor',
+ '--title3', 'theirs', '--merged-filename', '$output', '--merge',
+ '$local', '$base', '$other']),
+ toolplugin('diffmerge', args=['--nosplash', '--merge',
+ '--caption=Mercurial Merge', '--title1=Base', '--title2=Mine',
+ '--title3=Theirs', '$base', '$output', '$other']),
+ toolplugin('p4merge', args=['$base', '$local', '$other', '$output'],
+ winreg_key='Software\\Perforce\\Environment',
+ winreg_name='P4INSTROOT', winreg_append='\\p4merge.exe'),
+ toolplugin('tortoisemerge', args=['/base:$output', '/mine:$local',
+ '/theirs:$other', '/merged:$output'], winreg_name='TMergePath',
+ winreg_key='Software\\TortoiseSVN'),
+ toolplugin('ecmerge', args=['$base', '$local', '$other',
+ '--mode=merge3', '--title0=base', '--title1=mine', '--title2=theirs',
+ '--to=$output'], winreg_name='Path', winreg_key=
+ 'Software\\Elli\xc3\xa9 Computing\\Merge'),
+ toolplugin('filemerge', args=['-left', '$other', '-right', '$local',
+ '-ancestor', '$base', '-merge', '$output']),
+ )
diff --git a/mercurial/hgmerge/_simplemerge.py b/mercurial/hgmerge/_simplemerge.py
new file mode 100644
--- /dev/null
+++ b/mercurial/hgmerge/_simplemerge.py
@@ -0,0 +1,573 @@
+#!/usr/bin/env python
+# Copyright (C) 2004, 2005 Canonical Ltd
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+
+# mbp: "you know that thing where cvs gives you conflict markers?"
+# s: "i hate that."
+
+from mercurial import demandimport
+demandimport.enable()
+
+from mercurial import util, mdiff, fancyopts
+from mercurial.i18n import _
+
+import os.path
+import sys
+
+class CantReprocessAndShowBase(Exception):
+ pass
+
+
+def warn(message):
+ global _ui
+ if _ui is not None:
+ _ui.warn(message)
+ else:
+ sys.stderr.write(message)
+ sys.stderr.flush()
+
+
+def intersect(ra, rb):
+ """Given two ranges return the range where they intersect or None.
+
+ >>> intersect((0, 10), (0, 6))
+ (0, 6)
+ >>> intersect((0, 10), (5, 15))
+ (5, 10)
+ >>> intersect((0, 10), (10, 15))
+ >>> intersect((0, 9), (10, 15))
+ >>> intersect((0, 9), (7, 15))
+ (7, 9)
+ """
+ assert ra[0] <= ra[1]
+ assert rb[0] <= rb[1]
+
+ sa = max(ra[0], rb[0])
+ sb = min(ra[1], rb[1])
+ if sa < sb:
+ return sa, sb
+ else:
+ return None
+
+
+def compare_range(a, astart, aend, b, bstart, bend):
+ """Compare a[astart:aend] == b[bstart:bend], without slicing.
+ """
+ if (aend-astart) != (bend-bstart):
+ return False
+ for ia, ib in zip(xrange(astart, aend), xrange(bstart, bend)):
+ if a[ia] != b[ib]:
+ return False
+ else:
+ return True
+
+
+
+
+class Merge3Text(object):
+ """3-way merge of texts.
+
+ Given strings BASE, OTHER, THIS, tries to produce a combined text
+ incorporating the changes from both BASE->OTHER and BASE->THIS."""
+ def __init__(self, basetext, atext, btext, base=None, a=None, b=None):
+ self.basetext = basetext
+ self.atext = atext
+ self.btext = btext
+ if base is None:
+ base = mdiff.splitnewlines(basetext)
+ if a is None:
+ a = mdiff.splitnewlines(atext)
+ if b is None:
+ b = mdiff.splitnewlines(btext)
+ self.base = base
+ self.a = a
+ self.b = b
+
+
+
+ def merge_lines(self,
+ name_a=None,
+ name_b=None,
+ name_base=None,
+ start_marker='<<<<<<<',
+ mid_marker='=======',
+ end_marker='>>>>>>>',
+ base_marker=None,
+ reprocess=False):
+ """Return merge in cvs-like form.
+ """
+ self.conflicts = False
+ newline = '\n'
+ if len(self.a) > 0:
+ if self.a[0].endswith('\r\n'):
+ newline = '\r\n'
+ elif self.a[0].endswith('\r'):
+ newline = '\r'
+ if base_marker and reprocess:
+ raise CantReprocessAndShowBase()
+ if name_a:
+ start_marker = start_marker + ' ' + name_a
+ if name_b:
+ end_marker = end_marker + ' ' + name_b
+ if name_base and base_marker:
+ base_marker = base_marker + ' ' + name_base
+ merge_regions = self.merge_regions()
+ if reprocess is True:
+ merge_regions = self.reprocess_merge_regions(merge_regions)
+ for t in merge_regions:
+ what = t[0]
+ if what == 'unchanged':
+ for i in range(t[1], t[2]):
+ yield self.base[i]
+ elif what == 'a' or what == 'same':
+ for i in range(t[1], t[2]):
+ yield self.a[i]
+ elif what == 'b':
+ for i in range(t[1], t[2]):
+ yield self.b[i]
+ elif what == 'conflict':
+ self.conflicts = True
+ yield start_marker + newline
+ for i in range(t[3], t[4]):
+ yield self.a[i]
+ if base_marker is not None:
+ yield base_marker + newline
+ for i in range(t[1], t[2]):
+ yield self.base[i]
+ yield mid_marker + newline
+ for i in range(t[5], t[6]):
+ yield self.b[i]
+ yield end_marker + newline
+ else:
+ raise ValueError(what)
+
+
+
+
+
+ def merge_annotated(self):
+ """Return merge with conflicts, showing origin of lines.
+
+ Most useful for debugging merge.
+ """
+ for t in self.merge_regions():
+ what = t[0]
+ if what == 'unchanged':
+ for i in range(t[1], t[2]):
+ yield 'u | ' + self.base[i]
+ elif what == 'a' or what == 'same':
+ for i in range(t[1], t[2]):
+ yield what[0] + ' | ' + self.a[i]
+ elif what == 'b':
+ for i in range(t[1], t[2]):
+ yield 'b | ' + self.b[i]
+ elif what == 'conflict':
+ yield '<<<<\n'
+ for i in range(t[3], t[4]):
+ yield 'A | ' + self.a[i]
+ yield '----\n'
+ for i in range(t[5], t[6]):
+ yield 'B | ' + self.b[i]
+ yield '>>>>\n'
+ else:
+ raise ValueError(what)
+
+
+
+
+
+ def merge_groups(self):
+ """Yield sequence of line groups. Each one is a tuple:
+
+ 'unchanged', lines
+ Lines unchanged from base
+
+ 'a', lines
+ Lines taken from a
+
+ 'same', lines
+ Lines taken from a (and equal to b)
+
+ 'b', lines
+ Lines taken from b
+
+ 'conflict', base_lines, a_lines, b_lines
+ Lines from base were changed to either a or b and conflict.
+ """
+ for t in self.merge_regions():
+ what = t[0]
+ if what == 'unchanged':
+ yield what, self.base[t[1]:t[2]]
+ elif what == 'a' or what == 'same':
+ yield what, self.a[t[1]:t[2]]
+ elif what == 'b':
+ yield what, self.b[t[1]:t[2]]
+ elif what == 'conflict':
+ yield (what,
+ self.base[t[1]:t[2]],
+ self.a[t[3]:t[4]],
+ self.b[t[5]:t[6]])
+ else:
+ raise ValueError(what)
+
+
+ def merge_regions(self):
+ """Return sequences of matching and conflicting regions.
+
+ This returns tuples, where the first value says what kind we
+ have:
+
+ 'unchanged', start, end
+ Take a region of base[start:end]
+
+ 'same', astart, aend
+ b and a are different from base but give the same result
+
+ 'a', start, end
+ Non-clashing insertion from a[start:end]
+
+ Method is as follows:
+
+ The two sequences align only on regions which match the base
+ and both descendents. These are found by doing a two-way diff
+ of each one against the base, and then finding the
+ intersections between those regions. These "sync regions"
+ are by definition unchanged in both and easily dealt with.
+
+ The regions in between can be in any of three cases:
+ conflicted, or changed on only one side.
+ """
+
+ # section a[0:ia] has been disposed of, etc
+ iz = ia = ib = 0
+
+ for zmatch, zend, amatch, aend, bmatch, bend in self.find_sync_regions():
+ #print 'match base [%d:%d]' % (zmatch, zend)
+
+ matchlen = zend - zmatch
+ assert matchlen >= 0
+ assert matchlen == (aend - amatch)
+ assert matchlen == (bend - bmatch)
+
+ len_a = amatch - ia
+ len_b = bmatch - ib
+ len_base = zmatch - iz
+ assert len_a >= 0
+ assert len_b >= 0
+ assert len_base >= 0
+
+ #print 'unmatched a=%d, b=%d' % (len_a, len_b)
+
+ if len_a or len_b:
+ # try to avoid actually slicing the lists
+ equal_a = compare_range(self.a, ia, amatch,
+ self.base, iz, zmatch)
+ equal_b = compare_range(self.b, ib, bmatch,
+ self.base, iz, zmatch)
+ same = compare_range(self.a, ia, amatch,
+ self.b, ib, bmatch)
+
+ if same:
+ yield 'same', ia, amatch
+ elif equal_a and not equal_b:
+ yield 'b', ib, bmatch
+ elif equal_b and not equal_a:
+ yield 'a', ia, amatch
+ elif not equal_a and not equal_b:
+ yield 'conflict', iz, zmatch, ia, amatch, ib, bmatch
+ else:
+ raise AssertionError("can't handle a=b=base but unmatched")
+
+ ia = amatch
+ ib = bmatch
+ iz = zmatch
+
+ # if the same part of the base was deleted on both sides
+ # that's OK, we can just skip it.
+
+
+ if matchlen > 0:
+ assert ia == amatch
+ assert ib == bmatch
+ assert iz == zmatch
+
+ yield 'unchanged', zmatch, zend
+ iz = zend
+ ia = aend
+ ib = bend
+
+
+ def reprocess_merge_regions(self, merge_regions):
+ """Where there are conflict regions, remove the agreed lines.
+
+ Lines where both A and B have made the same changes are
+ eliminated.
+ """
+ for region in merge_regions:
+ if region[0] != "conflict":
+ yield region
+ continue
+ type, iz, zmatch, ia, amatch, ib, bmatch = region
+ a_region = self.a[ia:amatch]
+ b_region = self.b[ib:bmatch]
+ matches = mdiff.get_matching_blocks(''.join(a_region),
+ ''.join(b_region))
+ next_a = ia
+ next_b = ib
+ for region_ia, region_ib, region_len in matches[:-1]:
+ region_ia += ia
+ region_ib += ib
+ reg = self.mismatch_region(next_a, region_ia, next_b,
+ region_ib)
+ if reg is not None:
+ yield reg
+ yield 'same', region_ia, region_len+region_ia
+ next_a = region_ia + region_len
+ next_b = region_ib + region_len
+ reg = self.mismatch_region(next_a, amatch, next_b, bmatch)
+ if reg is not None:
+ yield reg
+
+
+ def mismatch_region(next_a, region_ia, next_b, region_ib):
+ if next_a < region_ia or next_b < region_ib:
+ return 'conflict', None, None, next_a, region_ia, next_b, region_ib
+ mismatch_region = staticmethod(mismatch_region)
+
+
+ def find_sync_regions(self):
+ """Return a list of sync regions, where both descendents match the base.
+
+ Generates a list of (base1, base2, a1, a2, b1, b2). There is
+ always a zero-length sync region at the end of all the files.
+ """
+
+ ia = ib = 0
+ amatches = mdiff.get_matching_blocks(self.basetext, self.atext)
+ bmatches = mdiff.get_matching_blocks(self.basetext, self.btext)
+ len_a = len(amatches)
+ len_b = len(bmatches)
+
+ sl = []
+
+ while ia < len_a and ib < len_b:
+ abase, amatch, alen = amatches[ia]
+ bbase, bmatch, blen = bmatches[ib]
+
+ # there is an unconflicted block at i; how long does it
+ # extend? until whichever one ends earlier.
+ i = intersect((abase, abase+alen), (bbase, bbase+blen))
+ if i:
+ intbase = i[0]
+ intend = i[1]
+ intlen = intend - intbase
+
+ # found a match of base[i[0], i[1]]; this may be less than
+ # the region that matches in either one
+ assert intlen <= alen
+ assert intlen <= blen
+ assert abase <= intbase
+ assert bbase <= intbase
+
+ asub = amatch + (intbase - abase)
+ bsub = bmatch + (intbase - bbase)
+ aend = asub + intlen
+ bend = bsub + intlen
+
+ assert self.base[intbase:intend] == self.a[asub:aend], \
+ (self.base[intbase:intend], self.a[asub:aend])
+
+ assert self.base[intbase:intend] == self.b[bsub:bend]
+
+ sl.append((intbase, intend,
+ asub, aend,
+ bsub, bend))
+
+ # advance whichever one ends first in the base text
+ if (abase + alen) < (bbase + blen):
+ ia += 1
+ else:
+ ib += 1
+
+ intbase = len(self.base)
+ abase = len(self.a)
+ bbase = len(self.b)
+ sl.append((intbase, intbase, abase, abase, bbase, bbase))
+
+ return sl
+
+
+
+ def find_unconflicted(self):
+ """Return a list of ranges in base that are not conflicted."""
+ am = mdiff.get_matching_blocks(self.basetext, self.atext)
+ bm = mdiff.get_matching_blocks(self.basetext, self.btext)
+
+ unc = []
+
+ while am and bm:
+ # there is an unconflicted block at i; how long does it
+ # extend? until whichever one ends earlier.
+ a1 = am[0][0]
+ a2 = a1 + am[0][2]
+ b1 = bm[0][0]
+ b2 = b1 + bm[0][2]
+ i = intersect((a1, a2), (b1, b2))
+ if i:
+ unc.append(i)
+
+ if a2 < b2:
+ del am[0]
+ else:
+ del bm[0]
+
+ return unc
+
+
+# bzr compatible interface, for the tests
+class Merge3(Merge3Text):
+ """3-way merge of texts.
+
+ Given BASE, OTHER, THIS, tries to produce a combined text
+ incorporating the changes from both BASE->OTHER and BASE->THIS.
+ All three will typically be sequences of lines."""
+ def __init__(self, base, a, b):
+ basetext = '\n'.join([i.strip('\n') for i in base] + [''])
+ atext = '\n'.join([i.strip('\n') for i in a] + [''])
+ btext = '\n'.join([i.strip('\n') for i in b] + [''])
+ if util.binary(basetext) or util.binary(atext) or util.binary(btext):
+ raise util.Abort(_("don't know how to merge binary files"))
+ Merge3Text.__init__(self, basetext, atext, btext, base, a, b)
+
+
+def simplemerge(local, base, other, output, ui=None, **opts):
+ ''' Simple three-way file merge.
+ @return: 0 - success, 1 - conflict, other integer - error.
+ '''
+ def readfile(filename):
+ f = open(filename, "rb")
+ text = f.read()
+ f.close()
+ if util.binary(text):
+ msg = _("%s looks like a binary file.") % filename
+ if not opts.get('text'):
+ raise util.Abort(msg)
+ elif not opts.get('quiet'):
+ warn(_('warning: %s\n') % msg)
+ return text
+
+ global _ui
+ _ui = ui
+
+ name_a = local
+ name_b = other
+ labels = opts.get('label', [])
+ if labels:
+ name_a = labels.pop(0)
+ if labels:
+ name_b = labels.pop(0)
+ if labels:
+ raise util.Abort(_("can only specify two labels."))
+
+ localtext = readfile(local)
+ basetext = readfile(base)
+ othertext = readfile(other)
+
+ output = os.path.realpath(output)
+ if not opts.get('print'):
+ opener = util.opener(os.path.dirname(output))
+ out = opener(os.path.basename(output), "w", atomictemp=True)
+ else:
+ out = sys.stdout
+
+ reprocess = not opts.get('no_minimal')
+
+ m3 = Merge3Text(basetext, localtext, othertext)
+ for line in m3.merge_lines(name_a=name_a, name_b=name_b,
+ reprocess=reprocess):
+ out.write(line)
+
+ if not opts.get('print'):
+ out.rename()
+
+ if m3.conflicts:
+ if not opts.get('quiet'):
+ warn(_("warning: conflicts during merge.\n"))
+ return 1
+
+ return 0
+
+options = [('L', 'label', [], _('labels to use on conflict markers')),
+ ('a', 'text', None, _('treat all files as text')),
+ ('p', 'print', None,
+ _('print results instead of overwriting LOCAL')),
+ ('', 'no-minimal', None,
+ _('do not try to minimize conflict regions')),
+ ('h', 'help', None, _('display help and exit')),
+ ('q', 'quiet', None, _('suppress output'))]
+
+usage = _('''simplemerge [OPTS] LOCAL BASE OTHER
+
+ Simple three-way file merge utility with a minimal feature set.
+
+ Apply to LOCAL the changes necessary to go from BASE to OTHER.
+
+ By default, LOCAL is overwritten with the results of this operation.
+''')
+
+def showhelp():
+ sys.stdout.write(usage)
+ sys.stdout.write('\noptions:\n')
+
+ out_opts = []
+ for shortopt, longopt, default, desc in options:
+ out_opts.append(('%2s%s' % (shortopt and '-%s' % shortopt,
+ longopt and ' --%s' % longopt),
+ '%s' % desc))
+ opts_len = max([len(opt[0]) for opt in out_opts])
+ for first, second in out_opts:
+ sys.stdout.write(' %-*s %s\n' % (opts_len, first, second))
+
+class ParseError(Exception):
+ """Exception raised on errors in parsing the command line."""
+
+def main(argv):
+ try:
+ opts = {}
+ try:
+ args = fancyopts.fancyopts(argv[1:], options, opts)
+ except fancyopts.getopt.GetoptError, e:
+ raise ParseError(e)
+ if opts['help']:
+ showhelp()
+ return 0
+ if len(args) != 3:
+ raise ParseError(_('wrong number of arguments'))
+ local, base, other = args
+ return simplemerge(local, base, other, local, **opts)
+ except ParseError, e:
+ sys.stdout.write("%s: %s\n" % (sys.argv[0], e))
+ showhelp()
+ return 1
+ except util.Abort, e:
+ sys.stderr.write("abort: %s\n" % e)
+ return 255
+ except KeyboardInterrupt:
+ return 255
+
+if __name__ == '__main__':
+ sys.exit(main(sys.argv))
More information about the Mercurial-devel
mailing list