1# extdiff.py - external diff program support for mercurial
2#
3# Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4#
5# This software may be used and distributed according to the terms of the
6# GNU General Public License version 2 or any later version.
7
8'''command to allow external programs to compare revisions
9
10The extdiff Mercurial extension allows you to use external programs
11to compare revisions, or revision with working directory. The external
12diff programs are called with a configurable set of options and two
13non-option arguments: paths to directories containing snapshots of
14files to compare.
15
16If there is more than one file being compared and the "child" revision
17is the working directory, any modifications made in the external diff
18program will be copied back to the working directory from the temporary
19directory.
20
21The extdiff extension also allows you to configure new diff commands, so
22you do not need to type :hg:`extdiff -p kdiff3` always. ::
23
24  [extdiff]
25  # add new command that runs GNU diff(1) in 'context diff' mode
26  cdiff = gdiff -Nprc5
27  ## or the old way:
28  #cmd.cdiff = gdiff
29  #opts.cdiff = -Nprc5
30
31  # add new command called meld, runs meld (no need to name twice).  If
32  # the meld executable is not available, the meld tool in [merge-tools]
33  # will be used, if available
34  meld =
35
36  # add new command called vimdiff, runs gvimdiff with DirDiff plugin
37  # (see http://www.vim.org/scripts/script.php?script_id=102) Non
38  # English user, be sure to put "let g:DirDiffDynamicDiffText = 1" in
39  # your .vimrc
40  vimdiff = gvim -f "+next" \\
41            "+execute 'DirDiff' fnameescape(argv(0)) fnameescape(argv(1))"
42
43Tool arguments can include variables that are expanded at runtime::
44
45  $parent1, $plabel1 - filename, descriptive label of first parent
46  $child,   $clabel  - filename, descriptive label of child revision
47  $parent2, $plabel2 - filename, descriptive label of second parent
48  $root              - repository root
49  $parent is an alias for $parent1.
50
51The extdiff extension will look in your [diff-tools] and [merge-tools]
52sections for diff tool arguments, when none are specified in [extdiff].
53
54::
55
56  [extdiff]
57  kdiff3 =
58
59  [diff-tools]
60  kdiff3.diffargs=--L1 '$plabel1' --L2 '$clabel' $parent $child
61
62If a program has a graphical interface, it might be interesting to tell
63Mercurial about it. It will prevent the program from being mistakenly
64used in a terminal-only environment (such as an SSH terminal session),
65and will make :hg:`extdiff --per-file` open multiple file diffs at once
66instead of one by one (if you still want to open file diffs one by one,
67you can use the --confirm option).
68
69Declaring that a tool has a graphical interface can be done with the
70``gui`` flag next to where ``diffargs`` are specified:
71
72::
73
74  [diff-tools]
75  kdiff3.diffargs=--L1 '$plabel1' --L2 '$clabel' $parent $child
76  kdiff3.gui = true
77
78You can use -I/-X and list of file or directory names like normal
79:hg:`diff` command. The extdiff extension makes snapshots of only
80needed files, so running the external diff program will actually be
81pretty fast (at least faster than having to compare the entire tree).
82'''
83
84from __future__ import absolute_import
85
86import os
87import re
88import shutil
89import stat
90import subprocess
91
92from mercurial.i18n import _
93from mercurial.node import (
94    nullrev,
95    short,
96)
97from mercurial import (
98    archival,
99    cmdutil,
100    encoding,
101    error,
102    filemerge,
103    formatter,
104    logcmdutil,
105    pycompat,
106    registrar,
107    scmutil,
108    util,
109)
110from mercurial.utils import (
111    procutil,
112    stringutil,
113)
114
115cmdtable = {}
116command = registrar.command(cmdtable)
117
118configtable = {}
119configitem = registrar.configitem(configtable)
120
121configitem(
122    b'extdiff',
123    br'opts\..*',
124    default=b'',
125    generic=True,
126)
127
128configitem(
129    b'extdiff',
130    br'gui\..*',
131    generic=True,
132)
133
134configitem(
135    b'diff-tools',
136    br'.*\.diffargs$',
137    default=None,
138    generic=True,
139)
140
141configitem(
142    b'diff-tools',
143    br'.*\.gui$',
144    generic=True,
145)
146
147# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
148# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
149# be specifying the version(s) of Mercurial they are tested with, or
150# leave the attribute unspecified.
151testedwith = b'ships-with-hg-core'
152
153
154def snapshot(ui, repo, files, node, tmproot, listsubrepos):
155    """snapshot files as of some revision
156    if not using snapshot, -I/-X does not work and recursive diff
157    in tools like kdiff3 and meld displays too many files."""
158    dirname = os.path.basename(repo.root)
159    if dirname == b"":
160        dirname = b"root"
161    if node is not None:
162        dirname = b'%s.%s' % (dirname, short(node))
163    base = os.path.join(tmproot, dirname)
164    os.mkdir(base)
165    fnsandstat = []
166
167    if node is not None:
168        ui.note(
169            _(b'making snapshot of %d files from rev %s\n')
170            % (len(files), short(node))
171        )
172    else:
173        ui.note(
174            _(b'making snapshot of %d files from working directory\n')
175            % (len(files))
176        )
177
178    if files:
179        repo.ui.setconfig(b"ui", b"archivemeta", False)
180
181        archival.archive(
182            repo,
183            base,
184            node,
185            b'files',
186            match=scmutil.matchfiles(repo, files),
187            subrepos=listsubrepos,
188        )
189
190        for fn in sorted(files):
191            wfn = util.pconvert(fn)
192            ui.note(b'  %s\n' % wfn)
193
194            if node is None:
195                dest = os.path.join(base, wfn)
196
197                fnsandstat.append((dest, repo.wjoin(fn), os.lstat(dest)))
198    return dirname, fnsandstat
199
200
201def formatcmdline(
202    cmdline,
203    repo_root,
204    do3way,
205    parent1,
206    plabel1,
207    parent2,
208    plabel2,
209    child,
210    clabel,
211):
212    # Function to quote file/dir names in the argument string.
213    # When not operating in 3-way mode, an empty string is
214    # returned for parent2
215    replace = {
216        b'parent': parent1,
217        b'parent1': parent1,
218        b'parent2': parent2,
219        b'plabel1': plabel1,
220        b'plabel2': plabel2,
221        b'child': child,
222        b'clabel': clabel,
223        b'root': repo_root,
224    }
225
226    def quote(match):
227        pre = match.group(2)
228        key = match.group(3)
229        if not do3way and key == b'parent2':
230            return pre
231        return pre + procutil.shellquote(replace[key])
232
233    # Match parent2 first, so 'parent1?' will match both parent1 and parent
234    regex = (
235        br'''(['"]?)([^\s'"$]*)'''
236        br'\$(parent2|parent1?|child|plabel1|plabel2|clabel|root)\1'
237    )
238    if not do3way and not re.search(regex, cmdline):
239        cmdline += b' $parent1 $child'
240    return re.sub(regex, quote, cmdline)
241
242
243def _systembackground(cmd, environ=None, cwd=None):
244    """like 'procutil.system', but returns the Popen object directly
245    so we don't have to wait on it.
246    """
247    env = procutil.shellenviron(environ)
248    proc = subprocess.Popen(
249        procutil.tonativestr(cmd),
250        shell=True,
251        close_fds=procutil.closefds,
252        env=procutil.tonativeenv(env),
253        cwd=pycompat.rapply(procutil.tonativestr, cwd),
254    )
255    return proc
256
257
258def _runperfilediff(
259    cmdline,
260    repo_root,
261    ui,
262    guitool,
263    do3way,
264    confirm,
265    commonfiles,
266    tmproot,
267    dir1a,
268    dir1b,
269    dir2,
270    rev1a,
271    rev1b,
272    rev2,
273):
274    # Note that we need to sort the list of files because it was
275    # built in an "unstable" way and it's annoying to get files in a
276    # random order, especially when "confirm" mode is enabled.
277    waitprocs = []
278    totalfiles = len(commonfiles)
279    for idx, commonfile in enumerate(sorted(commonfiles)):
280        path1a = os.path.join(dir1a, commonfile)
281        label1a = commonfile + rev1a
282        if not os.path.isfile(path1a):
283            path1a = pycompat.osdevnull
284
285        path1b = b''
286        label1b = b''
287        if do3way:
288            path1b = os.path.join(dir1b, commonfile)
289            label1b = commonfile + rev1b
290            if not os.path.isfile(path1b):
291                path1b = pycompat.osdevnull
292
293        path2 = os.path.join(dir2, commonfile)
294        label2 = commonfile + rev2
295
296        if confirm:
297            # Prompt before showing this diff
298            difffiles = _(b'diff %s (%d of %d)') % (
299                commonfile,
300                idx + 1,
301                totalfiles,
302            )
303            responses = _(
304                b'[Yns?]'
305                b'$$ &Yes, show diff'
306                b'$$ &No, skip this diff'
307                b'$$ &Skip remaining diffs'
308                b'$$ &? (display help)'
309            )
310            r = ui.promptchoice(b'%s %s' % (difffiles, responses))
311            if r == 3:  # ?
312                while r == 3:
313                    for c, t in ui.extractchoices(responses)[1]:
314                        ui.write(b'%s - %s\n' % (c, encoding.lower(t)))
315                    r = ui.promptchoice(b'%s %s' % (difffiles, responses))
316            if r == 0:  # yes
317                pass
318            elif r == 1:  # no
319                continue
320            elif r == 2:  # skip
321                break
322
323        curcmdline = formatcmdline(
324            cmdline,
325            repo_root,
326            do3way=do3way,
327            parent1=path1a,
328            plabel1=label1a,
329            parent2=path1b,
330            plabel2=label1b,
331            child=path2,
332            clabel=label2,
333        )
334
335        if confirm or not guitool:
336            # Run the comparison program and wait for it to exit
337            # before we show the next file.
338            # This is because either we need to wait for confirmation
339            # from the user between each invocation, or because, as far
340            # as we know, the tool doesn't have a GUI, in which case
341            # we can't run multiple CLI programs at the same time.
342            ui.debug(
343                b'running %r in %s\n' % (pycompat.bytestr(curcmdline), tmproot)
344            )
345            ui.system(curcmdline, cwd=tmproot, blockedtag=b'extdiff')
346        else:
347            # Run the comparison program but don't wait, as we're
348            # going to rapid-fire each file diff and then wait on
349            # the whole group.
350            ui.debug(
351                b'running %r in %s (backgrounded)\n'
352                % (pycompat.bytestr(curcmdline), tmproot)
353            )
354            proc = _systembackground(curcmdline, cwd=tmproot)
355            waitprocs.append(proc)
356
357    if waitprocs:
358        with ui.timeblockedsection(b'extdiff'):
359            for proc in waitprocs:
360                proc.wait()
361
362
363def diffpatch(ui, repo, node1, node2, tmproot, matcher, cmdline):
364    template = b'hg-%h.patch'
365    # write patches to temporary files
366    with formatter.nullformatter(ui, b'extdiff', {}) as fm:
367        cmdutil.export(
368            repo,
369            [repo[node1].rev(), repo[node2].rev()],
370            fm,
371            fntemplate=repo.vfs.reljoin(tmproot, template),
372            match=matcher,
373        )
374    label1 = cmdutil.makefilename(repo[node1], template)
375    label2 = cmdutil.makefilename(repo[node2], template)
376    file1 = repo.vfs.reljoin(tmproot, label1)
377    file2 = repo.vfs.reljoin(tmproot, label2)
378    cmdline = formatcmdline(
379        cmdline,
380        repo.root,
381        # no 3way while comparing patches
382        do3way=False,
383        parent1=file1,
384        plabel1=label1,
385        # while comparing patches, there is no second parent
386        parent2=None,
387        plabel2=None,
388        child=file2,
389        clabel=label2,
390    )
391    ui.debug(b'running %r in %s\n' % (pycompat.bytestr(cmdline), tmproot))
392    ui.system(cmdline, cwd=tmproot, blockedtag=b'extdiff')
393    return 1
394
395
396def diffrevs(
397    ui,
398    repo,
399    ctx1a,
400    ctx1b,
401    ctx2,
402    matcher,
403    tmproot,
404    cmdline,
405    do3way,
406    guitool,
407    opts,
408):
409
410    subrepos = opts.get(b'subrepos')
411
412    # calculate list of files changed between both revs
413    st = ctx1a.status(ctx2, matcher, listsubrepos=subrepos)
414    mod_a, add_a, rem_a = set(st.modified), set(st.added), set(st.removed)
415    if do3way:
416        stb = ctx1b.status(ctx2, matcher, listsubrepos=subrepos)
417        mod_b, add_b, rem_b = (
418            set(stb.modified),
419            set(stb.added),
420            set(stb.removed),
421        )
422    else:
423        mod_b, add_b, rem_b = set(), set(), set()
424    modadd = mod_a | add_a | mod_b | add_b
425    common = modadd | rem_a | rem_b
426    if not common:
427        return 0
428
429    # Always make a copy of ctx1a (and ctx1b, if applicable)
430    # dir1a should contain files which are:
431    #   * modified or removed from ctx1a to ctx2
432    #   * modified or added from ctx1b to ctx2
433    #     (except file added from ctx1a to ctx2 as they were not present in
434    #     ctx1a)
435    dir1a_files = mod_a | rem_a | ((mod_b | add_b) - add_a)
436    dir1a = snapshot(ui, repo, dir1a_files, ctx1a.node(), tmproot, subrepos)[0]
437    rev1a = b'' if ctx1a.rev() is None else b'@%d' % ctx1a.rev()
438    if do3way:
439        # file calculation criteria same as dir1a
440        dir1b_files = mod_b | rem_b | ((mod_a | add_a) - add_b)
441        dir1b = snapshot(
442            ui, repo, dir1b_files, ctx1b.node(), tmproot, subrepos
443        )[0]
444        rev1b = b'@%d' % ctx1b.rev()
445    else:
446        dir1b = None
447        rev1b = b''
448
449    fnsandstat = []
450
451    # If ctx2 is not the wc or there is >1 change, copy it
452    dir2root = b''
453    rev2 = b''
454    if ctx2.node() is not None:
455        dir2 = snapshot(ui, repo, modadd, ctx2.node(), tmproot, subrepos)[0]
456        rev2 = b'@%d' % ctx2.rev()
457    elif len(common) > 1:
458        # we only actually need to get the files to copy back to
459        # the working dir in this case (because the other cases
460        # are: diffing 2 revisions or single file -- in which case
461        # the file is already directly passed to the diff tool).
462        dir2, fnsandstat = snapshot(ui, repo, modadd, None, tmproot, subrepos)
463    else:
464        # This lets the diff tool open the changed file directly
465        dir2 = b''
466        dir2root = repo.root
467
468    label1a = rev1a
469    label1b = rev1b
470    label2 = rev2
471
472    if not opts.get(b'per_file'):
473        # If only one change, diff the files instead of the directories
474        # Handle bogus modifies correctly by checking if the files exist
475        if len(common) == 1:
476            common_file = util.localpath(common.pop())
477            dir1a = os.path.join(tmproot, dir1a, common_file)
478            label1a = common_file + rev1a
479            if not os.path.isfile(dir1a):
480                dir1a = pycompat.osdevnull
481            if do3way:
482                dir1b = os.path.join(tmproot, dir1b, common_file)
483                label1b = common_file + rev1b
484                if not os.path.isfile(dir1b):
485                    dir1b = pycompat.osdevnull
486            dir2 = os.path.join(dir2root, dir2, common_file)
487            label2 = common_file + rev2
488
489        # Run the external tool on the 2 temp directories or the patches
490        cmdline = formatcmdline(
491            cmdline,
492            repo.root,
493            do3way=do3way,
494            parent1=dir1a,
495            plabel1=label1a,
496            parent2=dir1b,
497            plabel2=label1b,
498            child=dir2,
499            clabel=label2,
500        )
501        ui.debug(b'running %r in %s\n' % (pycompat.bytestr(cmdline), tmproot))
502        ui.system(cmdline, cwd=tmproot, blockedtag=b'extdiff')
503    else:
504        # Run the external tool once for each pair of files
505        _runperfilediff(
506            cmdline,
507            repo.root,
508            ui,
509            guitool=guitool,
510            do3way=do3way,
511            confirm=opts.get(b'confirm'),
512            commonfiles=common,
513            tmproot=tmproot,
514            dir1a=os.path.join(tmproot, dir1a),
515            dir1b=os.path.join(tmproot, dir1b) if do3way else None,
516            dir2=os.path.join(dir2root, dir2),
517            rev1a=rev1a,
518            rev1b=rev1b,
519            rev2=rev2,
520        )
521
522    for copy_fn, working_fn, st in fnsandstat:
523        cpstat = os.lstat(copy_fn)
524        # Some tools copy the file and attributes, so mtime may not detect
525        # all changes.  A size check will detect more cases, but not all.
526        # The only certain way to detect every case is to diff all files,
527        # which could be expensive.
528        # copyfile() carries over the permission, so the mode check could
529        # be in an 'elif' branch, but for the case where the file has
530        # changed without affecting mtime or size.
531        if (
532            cpstat[stat.ST_MTIME] != st[stat.ST_MTIME]
533            or cpstat.st_size != st.st_size
534            or (cpstat.st_mode & 0o100) != (st.st_mode & 0o100)
535        ):
536            ui.debug(
537                b'file changed while diffing. '
538                b'Overwriting: %s (src: %s)\n' % (working_fn, copy_fn)
539            )
540            util.copyfile(copy_fn, working_fn)
541
542    return 1
543
544
545def dodiff(ui, repo, cmdline, pats, opts, guitool=False):
546    """Do the actual diff:
547
548    - copy to a temp structure if diffing 2 internal revisions
549    - copy to a temp structure if diffing working revision with
550      another one and more than 1 file is changed
551    - just invoke the diff for a single file in the working dir
552    """
553
554    cmdutil.check_at_most_one_arg(opts, b'rev', b'change')
555    revs = opts.get(b'rev')
556    from_rev = opts.get(b'from')
557    to_rev = opts.get(b'to')
558    change = opts.get(b'change')
559    do3way = b'$parent2' in cmdline
560
561    if change:
562        ctx2 = logcmdutil.revsingle(repo, change, None)
563        ctx1a, ctx1b = ctx2.p1(), ctx2.p2()
564    elif from_rev or to_rev:
565        repo = scmutil.unhidehashlikerevs(
566            repo, [from_rev] + [to_rev], b'nowarn'
567        )
568        ctx1a = logcmdutil.revsingle(repo, from_rev, None)
569        ctx1b = repo[nullrev]
570        ctx2 = logcmdutil.revsingle(repo, to_rev, None)
571    else:
572        ctx1a, ctx2 = logcmdutil.revpair(repo, revs)
573        if not revs:
574            ctx1b = repo[None].p2()
575        else:
576            ctx1b = repo[nullrev]
577
578    # Disable 3-way merge if there is only one parent
579    if do3way:
580        if ctx1b.rev() == nullrev:
581            do3way = False
582
583    matcher = scmutil.match(ctx2, pats, opts)
584
585    if opts.get(b'patch'):
586        if opts.get(b'subrepos'):
587            raise error.Abort(_(b'--patch cannot be used with --subrepos'))
588        if opts.get(b'per_file'):
589            raise error.Abort(_(b'--patch cannot be used with --per-file'))
590        if ctx2.node() is None:
591            raise error.Abort(_(b'--patch requires two revisions'))
592
593    tmproot = pycompat.mkdtemp(prefix=b'extdiff.')
594    try:
595        if opts.get(b'patch'):
596            return diffpatch(
597                ui, repo, ctx1a.node(), ctx2.node(), tmproot, matcher, cmdline
598            )
599
600        return diffrevs(
601            ui,
602            repo,
603            ctx1a,
604            ctx1b,
605            ctx2,
606            matcher,
607            tmproot,
608            cmdline,
609            do3way,
610            guitool,
611            opts,
612        )
613
614    finally:
615        ui.note(_(b'cleaning up temp directory\n'))
616        shutil.rmtree(tmproot)
617
618
619extdiffopts = (
620    [
621        (
622            b'o',
623            b'option',
624            [],
625            _(b'pass option to comparison program'),
626            _(b'OPT'),
627        ),
628        (b'r', b'rev', [], _(b'revision (DEPRECATED)'), _(b'REV')),
629        (b'', b'from', b'', _(b'revision to diff from'), _(b'REV1')),
630        (b'', b'to', b'', _(b'revision to diff to'), _(b'REV2')),
631        (b'c', b'change', b'', _(b'change made by revision'), _(b'REV')),
632        (
633            b'',
634            b'per-file',
635            False,
636            _(b'compare each file instead of revision snapshots'),
637        ),
638        (
639            b'',
640            b'confirm',
641            False,
642            _(b'prompt user before each external program invocation'),
643        ),
644        (b'', b'patch', None, _(b'compare patches for two revisions')),
645    ]
646    + cmdutil.walkopts
647    + cmdutil.subrepoopts
648)
649
650
651@command(
652    b'extdiff',
653    [
654        (b'p', b'program', b'', _(b'comparison program to run'), _(b'CMD')),
655    ]
656    + extdiffopts,
657    _(b'hg extdiff [OPT]... [FILE]...'),
658    helpcategory=command.CATEGORY_FILE_CONTENTS,
659    inferrepo=True,
660)
661def extdiff(ui, repo, *pats, **opts):
662    """use external program to diff repository (or selected files)
663
664    Show differences between revisions for the specified files, using
665    an external program. The default program used is diff, with
666    default options "-Npru".
667
668    To select a different program, use the -p/--program option. The
669    program will be passed the names of two directories to compare,
670    unless the --per-file option is specified (see below). To pass
671    additional options to the program, use -o/--option. These will be
672    passed before the names of the directories or files to compare.
673
674    The --from, --to, and --change options work the same way they do for
675    :hg:`diff`.
676
677    The --per-file option runs the external program repeatedly on each
678    file to diff, instead of once on two directories. By default,
679    this happens one by one, where the next file diff is open in the
680    external program only once the previous external program (for the
681    previous file diff) has exited. If the external program has a
682    graphical interface, it can open all the file diffs at once instead
683    of one by one. See :hg:`help -e extdiff` for information about how
684    to tell Mercurial that a given program has a graphical interface.
685
686    The --confirm option will prompt the user before each invocation of
687    the external program. It is ignored if --per-file isn't specified.
688    """
689    opts = pycompat.byteskwargs(opts)
690    program = opts.get(b'program')
691    option = opts.get(b'option')
692    if not program:
693        program = b'diff'
694        option = option or [b'-Npru']
695    cmdline = b' '.join(map(procutil.shellquote, [program] + option))
696    return dodiff(ui, repo, cmdline, pats, opts)
697
698
699class savedcmd(object):
700    """use external program to diff repository (or selected files)
701
702    Show differences between revisions for the specified files, using
703    the following program::
704
705        %(path)s
706
707    When two revision arguments are given, then changes are shown
708    between those revisions. If only one revision is specified then
709    that revision is compared to the working directory, and, when no
710    revisions are specified, the working directory files are compared
711    to its parent.
712    """
713
714    def __init__(self, path, cmdline, isgui):
715        # We can't pass non-ASCII through docstrings (and path is
716        # in an unknown encoding anyway), but avoid double separators on
717        # Windows
718        docpath = stringutil.escapestr(path).replace(b'\\\\', b'\\')
719        self.__doc__ %= {'path': pycompat.sysstr(stringutil.uirepr(docpath))}
720        self._cmdline = cmdline
721        self._isgui = isgui
722
723    def __call__(self, ui, repo, *pats, **opts):
724        opts = pycompat.byteskwargs(opts)
725        options = b' '.join(map(procutil.shellquote, opts[b'option']))
726        if options:
727            options = b' ' + options
728        return dodiff(
729            ui, repo, self._cmdline + options, pats, opts, guitool=self._isgui
730        )
731
732
733def _gettooldetails(ui, cmd, path):
734    """
735    returns following things for a
736    ```
737    [extdiff]
738    <cmd> = <path>
739    ```
740    entry:
741
742    cmd: command/tool name
743    path: path to the tool
744    cmdline: the command which should be run
745    isgui: whether the tool uses GUI or not
746
747    Reads all external tools related configs, whether it be extdiff section,
748    diff-tools or merge-tools section, or its specified in an old format or
749    the latest format.
750    """
751    path = util.expandpath(path)
752    if cmd.startswith(b'cmd.'):
753        cmd = cmd[4:]
754        if not path:
755            path = procutil.findexe(cmd)
756            if path is None:
757                path = filemerge.findexternaltool(ui, cmd) or cmd
758        diffopts = ui.config(b'extdiff', b'opts.' + cmd)
759        cmdline = procutil.shellquote(path)
760        if diffopts:
761            cmdline += b' ' + diffopts
762        isgui = ui.configbool(b'extdiff', b'gui.' + cmd)
763    else:
764        if path:
765            # case "cmd = path opts"
766            cmdline = path
767            diffopts = len(pycompat.shlexsplit(cmdline)) > 1
768        else:
769            # case "cmd ="
770            path = procutil.findexe(cmd)
771            if path is None:
772                path = filemerge.findexternaltool(ui, cmd) or cmd
773            cmdline = procutil.shellquote(path)
774            diffopts = False
775        isgui = ui.configbool(b'extdiff', b'gui.' + cmd)
776    # look for diff arguments in [diff-tools] then [merge-tools]
777    if not diffopts:
778        key = cmd + b'.diffargs'
779        for section in (b'diff-tools', b'merge-tools'):
780            args = ui.config(section, key)
781            if args:
782                cmdline += b' ' + args
783                if isgui is None:
784                    isgui = ui.configbool(section, cmd + b'.gui') or False
785                break
786    return cmd, path, cmdline, isgui
787
788
789def uisetup(ui):
790    for cmd, path in ui.configitems(b'extdiff'):
791        if cmd.startswith(b'opts.') or cmd.startswith(b'gui.'):
792            continue
793        cmd, path, cmdline, isgui = _gettooldetails(ui, cmd, path)
794        command(
795            cmd,
796            extdiffopts[:],
797            _(b'hg %s [OPTION]... [FILE]...') % cmd,
798            helpcategory=command.CATEGORY_FILE_CONTENTS,
799            inferrepo=True,
800        )(savedcmd(path, cmdline, isgui))
801
802
803# tell hggettext to extract docstrings from these functions:
804i18nfunctions = [savedcmd]
805