Patchwork filemerge: support specfiying a python function to custom merge-tools

login
register
mail settings
Submitter Tom Hindle
Date May 11, 2018, 6:20 p.m.
Message ID <b72357aa21eb1516c4f1.1526062805@hindlet-PC>
Download mbox | patch
Permalink /patch/31511/
State Superseded
Headers show

Comments

Tom Hindle - May 11, 2018, 6:20 p.m.
# HG changeset patch
# User hindlemail <tom_hindle@sil.org>
# Date 1526062578 21600
#      Fri May 11 12:16:18 2018 -0600
# Node ID b72357aa21eb1516c4f14446ddd4963558bd2806
# Parent  8b86acc7aa64130f5b6fa69f5fc20ef4d0b09c42
filemerge: support specfiying a python function to custom merge-tools

Eliminates the need to specify a python executable, which may not exist on
system. Additionally launching script inprocess aids portablity on systems that
can't execute python via the shell.
example usage "merge-tools.myTool.executable=python:c:\myTool.py:mergefn"
where myTool.py contains a function:
"def mergefn(ui, repo, args, **kwargs):"
where args is list of args passed to merge tool.
(by default, expanded:  $local $base $other)

invoking the specified python function was done by exposing and invoking
(hook._pythonhook -> hook.pythonhook)

Patch

diff -r 8b86acc7aa64 -r b72357aa21eb mercurial/filemerge.py
--- a/mercurial/filemerge.py	Sat Apr 28 23:16:41 2018 -0700
+++ b/mercurial/filemerge.py	Fri May 11 12:16:18 2018 -0600
@@ -114,6 +114,9 @@ 
 def _findtool(ui, tool):
     if tool in internals:
         return tool
+    cmd = _toolstr(ui, tool, "executable", tool)
+    if cmd.startswith('python:'):
+        return cmd
     return findexternaltool(ui, tool)
 
 def findexternaltool(ui, tool):
@@ -325,7 +328,7 @@ 
         return filectx
 
 def _premerge(repo, fcd, fco, fca, toolconf, files, labels=None):
-    tool, toolpath, binary, symlink = toolconf
+    tool, toolpath, binary, symlink, scriptfn = toolconf
     if symlink or fcd.isabsent() or fco.isabsent():
         return 1
     unused, unused, unused, back = files
@@ -361,7 +364,7 @@ 
     return 1 # continue merging
 
 def _mergecheck(repo, mynode, orig, fcd, fco, fca, toolconf):
-    tool, toolpath, binary, symlink = toolconf
+    tool, toolpath, binary, symlink, scriptfn = toolconf
     if symlink:
         repo.ui.warn(_('warning: internal %s cannot merge symlinks '
                        'for %s\n') % (tool, fcd.path()))
@@ -430,7 +433,7 @@ 
     Generic driver for _imergelocal and _imergeother
     """
     assert localorother is not None
-    tool, toolpath, binary, symlink = toolconf
+    tool, toolpath, binary, symlink, scriptfn = toolconf
     r = simplemerge.simplemerge(repo.ui, fcd, fca, fco, label=labels,
                                 localorother=localorother)
     return True, r
@@ -510,7 +513,7 @@ 
                                             'external merge tools')
 
 def _xmerge(repo, mynode, orig, fcd, fco, fca, toolconf, files, labels=None):
-    tool, toolpath, binary, symlink = toolconf
+    tool, toolpath, binary, symlink, scriptfn = toolconf
     if fcd.isabsent() or fco.isabsent():
         repo.ui.warn(_('warning: %s cannot merge change/delete conflict '
                        'for %s\n') % (tool, fcd.path()))
@@ -551,12 +554,36 @@ 
         args = util.interpolate(
             br'\$', replace, args,
             lambda s: procutil.shellquote(util.localpath(s)))
-        cmd = toolpath + ' ' + args
         if _toolbool(ui, tool, "gui"):
             repo.ui.status(_('running merge tool %s for file %s\n') %
                            (tool, fcd.path()))
-        repo.ui.debug('launching merge tool: %s\n' % cmd)
-        r = ui.system(cmd, cwd=repo.root, environ=env, blockedtag='mergetool')
+        if scriptfn is None:
+            cmd = toolpath + ' ' + args
+            repo.ui.debug('launching merge tool: %s\n' % cmd)
+            r = ui.system(cmd, cwd=repo.root, environ=env,
+                          blockedtag='mergetool')
+        else:
+            repo.ui.debug('launching python merge script: %s:%s\n' %
+                          (toolpath, scriptfn))
+            r = 0
+            try:
+                # avoid cycle cmdutil -> merge -> filemerge -> extensions -> cmdutil
+                from . import extensions
+                mod = extensions.loadpath(toolpath, 'hgmerge.%s' % scriptfn)
+            except Exception:
+                raise error.Abort(_("loading python merge script failed: %s") %
+                         toolpath)
+            mergefn = getattr(mod, scriptfn, None)
+            if mergefn is None:
+                raise error.Abort(_("%s does not have function: %s") %
+                                  (toolpath, scriptfn))
+            argslist = procutil.shellsplit(args)
+            # avoid cycle cmdutil -> merge -> filemerge -> hook -> extensions -> cmdutil
+            from . import hook
+            ret, raised = hook.pythonhook(ui, repo, "merge", toolpath,
+                                          mergefn, {'args' : argslist}, True)
+            if raised:
+                r = 1
         repo.ui.debug('merge tool returned: %d\n' % r)
         return True, r, False
 
@@ -751,9 +778,25 @@ 
     symlink = 'l' in fcd.flags() + fco.flags()
     changedelete = fcd.isabsent() or fco.isabsent()
     tool, toolpath = _picktool(repo, ui, fd, binary, symlink, changedelete)
+    scriptfn = None
     if tool in internals and tool.startswith('internal:'):
         # normalize to new-style names (':merge' etc)
         tool = tool[len('internal'):]
+    if toolpath and procutil.shellsplit(toolpath)[0].startswith('python:'):
+        invalidsyntax = False
+        if toolpath.count(':') >= 2:
+            script, scriptfn = procutil.shellsplit(toolpath)[0][7:].rsplit(':',
+                                                                           1)
+            if not scriptfn:
+                invalidsyntax = True
+            # missing :callable can lead to spliting on windows drive letter
+            if '\\' in scriptfn or '/' in scriptfn:
+                invalidsyntax = True
+        else:
+            invalidsyntax = True
+        if invalidsyntax:
+            raise error.Abort(_("invalid 'python:' syntax: %s") % toolpath)
+        toolpath = script
     ui.debug("picked tool '%s' for %s (binary %s symlink %s changedelete %s)\n"
              % (tool, fd, pycompat.bytestr(binary), pycompat.bytestr(symlink),
                     pycompat.bytestr(changedelete)))
@@ -774,7 +817,7 @@ 
         precheck = None
         isexternal = True
 
-    toolconf = tool, toolpath, binary, symlink
+    toolconf = tool, toolpath, binary, symlink, scriptfn
 
     if mergetype == nomerge:
         r, deleted = func(repo, mynode, orig, fcd, fco, fca, toolconf, labels)
diff -r 8b86acc7aa64 -r b72357aa21eb mercurial/hook.py
--- a/mercurial/hook.py	Sat Apr 28 23:16:41 2018 -0700
+++ b/mercurial/hook.py	Fri May 11 12:16:18 2018 -0600
@@ -24,7 +24,7 @@ 
     stringutil,
 )
 
-def _pythonhook(ui, repo, htype, hname, funcname, args, throw):
+def pythonhook(ui, repo, htype, hname, funcname, args, throw):
     '''call python hook. hook is callable object, looked up as
     name in python module. if callable returns "true", hook
     fails, else passes. if hook raises exception, treated as
@@ -242,7 +242,7 @@ 
                 r = 1
                 raised = False
             elif callable(cmd):
-                r, raised = _pythonhook(ui, repo, htype, hname, cmd, args,
+                r, raised = pythonhook(ui, repo, htype, hname, cmd, args,
                                         throw)
             elif cmd.startswith('python:'):
                 if cmd.count(':') >= 2:
@@ -258,7 +258,7 @@ 
                     hookfn = getattr(mod, cmd)
                 else:
                     hookfn = cmd[7:].strip()
-                r, raised = _pythonhook(ui, repo, htype, hname, hookfn, args,
+                r, raised = pythonhook(ui, repo, htype, hname, hookfn, args,
                                         throw)
             else:
                 r = _exthook(ui, repo, htype, hname, cmd, args, throw)
diff -r 8b86acc7aa64 -r b72357aa21eb tests/test-merge-tools.t
--- a/tests/test-merge-tools.t	Sat Apr 28 23:16:41 2018 -0700
+++ b/tests/test-merge-tools.t	Fri May 11 12:16:18 2018 -0600
@@ -328,6 +328,157 @@ 
   # hg resolve --list
   R f
 
+executable set to python script that succeeds:
+
+  $ cat > /tmp/myworkingmerge.py <<EOF
+  > def myworkingmergefn(ui, repo, args, **kwargs):
+  >     return False;
+  > EOF
+  $ beforemerge
+  [merge-tools]
+  false.whatever=
+  true.priority=1
+  true.executable=cat
+  # hg update -C 1
+  $ hg merge -r 2 --config merge-tools.true.executable=python:/tmp/myworkingmerge.py:myworkingmergefn
+  merging f
+  0 files updated, 1 files merged, 0 files removed, 0 files unresolved
+  (branch merge, don't forget to commit)
+  $ aftermerge
+  # cat f
+  revision 1
+  space
+  # hg stat
+  M f
+  # hg resolve --list
+  R f
+
+executable set to python script that fails:
+
+  $ cat > /tmp/mybrokenmerge.py <<EOF
+  > def mybrokenmergefn(ui, repo, args, **kwargs):
+  >     ui.write("some fail message\n")
+  >     return True;
+  > EOF
+  $ beforemerge
+  [merge-tools]
+  false.whatever=
+  true.priority=1
+  true.executable=cat
+  # hg update -C 1
+  $ hg merge -r 2 --config merge-tools.true.executable=python:/tmp/mybrokenmerge.py:mybrokenmergefn
+  merging f
+  some fail message
+  abort: /tmp/mybrokenmerge.py hook failed
+  [255]
+  $ aftermerge
+  # cat f
+  revision 1
+  space
+  # hg stat
+  ? f.orig
+  # hg resolve --list
+  U f
+
+executable set to python script this is missing function:
+
+  $ beforemerge
+  [merge-tools]
+  false.whatever=
+  true.priority=1
+  true.executable=cat
+  # hg update -C 1
+  $ hg merge -r 2 --config merge-tools.true.executable=python:/tmp/myworkingmerge.py:missingFunction
+  merging f
+  abort: /tmp/myworkingmerge.py does not have function: missingFunction
+  [255]
+  $ aftermerge
+  # cat f
+  revision 1
+  space
+  # hg stat
+  ? f.orig
+  # hg resolve --list
+  U f
+
+executable set to missing python script:
+
+  $ beforemerge
+  [merge-tools]
+  false.whatever=
+  true.priority=1
+  true.executable=cat
+  # hg update -C 1
+  $ hg merge -r 2 --config merge-tools.true.executable=python:/tmp/missingpythonscript.py:mergefn
+  merging f
+  abort: loading python merge script failed: /tmp/missingpythonscript.py
+  [255]
+  $ aftermerge
+  # cat f
+  revision 1
+  space
+  # hg stat
+  ? f.orig
+  # hg resolve --list
+  U f
+
+executable set to python script but callable function is missing:
+
+  $ beforemerge
+  [merge-tools]
+  false.whatever=
+  true.priority=1
+  true.executable=cat
+  # hg update -C 1
+  $ hg merge -r 2 --config merge-tools.true.executable=python:/tmp/myworkingmerge.py
+  abort: invalid 'python:' syntax: 'python:/tmp/myworkingmerge.py'
+  [255]
+  $ aftermerge
+  # cat f
+  revision 1
+  space
+  # hg stat
+  # hg resolve --list
+  U f
+
+executable set to python script but callable function is empty string:
+
+  $ beforemerge
+  [merge-tools]
+  false.whatever=
+  true.priority=1
+  true.executable=cat
+  # hg update -C 1
+  $ hg merge -r 2 --config merge-tools.true.executable=python:/tmp/myworkingmerge.py:
+  abort: invalid 'python:' syntax: 'python:/tmp/myworkingmerge.py:'
+  [255]
+  $ aftermerge
+  # cat f
+  revision 1
+  space
+  # hg stat
+  # hg resolve --list
+  U f
+
+executable set to python script but callable function is missing and path contains colon:
+
+  $ beforemerge
+  [merge-tools]
+  false.whatever=
+  true.priority=1
+  true.executable=cat
+  # hg update -C 1
+  $ hg merge -r 2 --config merge-tools.true.executable=python:/tmp/some:dir/myworkingmerge.py
+  abort: invalid 'python:' syntax: 'python:/tmp/some:dir/myworkingmerge.py'
+  [255]
+  $ aftermerge
+  # cat f
+  revision 1
+  space
+  # hg stat
+  # hg resolve --list
+  U f
+
 #if unix-permissions
 
 environment variables in true.executable are handled: