diff --git a/mercurial/filemerge.py b/mercurial/filemerge.py --- a/mercurial/filemerge.py +++ b/mercurial/filemerge.py @@ -114,8 +114,16 @@ class absentfilectx(object): 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 _quotetoolpath(cmd): + if cmd.startswith('python:'): + return cmd + return procutil.shellquote(cmd) + def findexternaltool(ui, tool): for kn in ("regkey", "regkeyalt"): k = _toolstr(ui, tool, kn) @@ -165,7 +173,7 @@ def _picktool(repo, ui, path, binary, sy return ":prompt", None else: if toolpath: - return (force, procutil.shellquote(toolpath)) + return (force, _quotetoolpath(toolpath)) else: # mimic HGMERGE if given tool not found return (force, force) @@ -183,7 +191,7 @@ def _picktool(repo, ui, path, binary, sy mf = match.match(repo.root, '', [pat]) if mf(path) and check(tool, pat, symlink, False, changedelete): toolpath = _findtool(ui, tool) - return (tool, procutil.shellquote(toolpath)) + return (tool, _quotetoolpath(toolpath)) # then merge tools tools = {} @@ -208,7 +216,7 @@ def _picktool(repo, ui, path, binary, sy for p, t in tools: if check(t, None, symlink, binary, changedelete): toolpath = _findtool(ui, t) - return (t, procutil.shellquote(toolpath)) + return (t, _quotetoolpath(toolpath)) # internal merge or prompt as last resort if symlink or binary or changedelete: @@ -325,7 +333,7 @@ def _underlyingfctxifabsent(filectx): 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 +369,7 @@ def _premerge(repo, fcd, fco, fca, toolc 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 +438,7 @@ def _imergeauto(repo, mynode, orig, fcd, 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 +518,7 @@ def _xmergeimm(repo, mynode, orig, fcd, '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 +559,36 @@ def _xmerge(repo, mynode, orig, fcd, fco 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 +783,24 @@ def _filemerge(premerge, repo, wctx, myn 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 toolpath.startswith('python:'): + invalidsyntax = False + if toolpath.count(':') >= 2: + script, scriptfn = toolpath[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 +821,7 @@ def _filemerge(premerge, repo, wctx, myn 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 --git a/mercurial/hook.py b/mercurial/hook.py --- a/mercurial/hook.py +++ b/mercurial/hook.py @@ -24,7 +24,7 @@ from .utils import ( 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 @@ def runhooks(ui, repo, htype, hooks, thr 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 @@ def runhooks(ui, repo, htype, hooks, thr 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 --git a/tests/test-merge-tools.t b/tests/test-merge-tools.t --- a/tests/test-merge-tools.t +++ b/tests/test-merge-tools.t @@ -329,6 +329,183 @@ and true.executable set to cat with path # hg resolve --list R f +executable set to python script that succeeds: + + $ cat > "$TESTTMP/myworkingmerge.py" < 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:$TESTTMP/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 > "$TESTTMP/mybrokenmerge.py" < def mybrokenmergefn(ui, repo, args, **kwargs): + > ui.write(b"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:$TESTTMP/mybrokenmerge.py:mybrokenmergefn" + merging f + some fail message + abort: $TESTTMP/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 that 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:$TESTTMP/myworkingmerge.py:missingFunction" + merging f + abort: $TESTTMP/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:$TESTTMP/missingpythonscript.py:mergefn" + merging f + abort: loading python merge script failed: $TESTTMP/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:$TESTTMP/myworkingmerge.py" + abort: invalid 'python:' syntax: python:$TESTTMP/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:$TESTTMP/myworkingmerge.py:" + abort: invalid 'python:' syntax: python:$TESTTMP/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:$TESTTMP/some:dir/myworkingmerge.py" + abort: invalid 'python:' syntax: python:$TESTTMP/some:dir/myworkingmerge.py + [255] + $ aftermerge + # cat f + revision 1 + space + # hg stat + # hg resolve --list + U f + +executable set to python script filename that contains spaces: + + $ mkdir -p "$TESTTMP/my path" + $ cat > "$TESTTMP/my path/my working merge with spaces in filename.py" < 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:$TESTTMP/my path/my working merge with spaces in filename.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 + #if unix-permissions environment variables in true.executable are handled: