[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