# dispatch.py - command dispatching for mercurial
#
# Copyright 2005-2007 Olivia Mackall <olivia@selenic.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

from __future__ import annotations

import errno
import getopt
import io
import os
import pdb
import re
import signal
import sys
import traceback
import typing

from .i18n import _

from hgdemandimport import tracing

from . import (
    cmd_impls,
    color,
    commands,
    demandimport,
    encoding,
    error,
    extensions,
    fancyopts,
    help,
    hook,
    initialization,
    main_script,
    profiling,
    pycompat,
    registrar,
    repo as repo_utils,
    scmutil,
    tables,
    ui as uimod,
    util,
)
from .main_script import (
    cmd_finder,
    options as args_util,
    paths as local_util,
)
from .repo import (
    factory as repo_factory,
)
from .utils import (
    procutil,
    stringutil,
)


initialization.init()


def _flushstdio(ui, err):
    status = None
    # In all cases we try to flush stdio streams.
    if hasattr(ui, 'fout'):
        assert ui is not None  # help pytype
        assert ui.fout is not None  # help pytype
        try:
            ui.fout.flush()
        except OSError as e:
            err = e
            status = -1

    if hasattr(ui, 'ferr'):
        assert ui is not None  # help pytype
        assert ui.ferr is not None  # help pytype
        try:
            if err is not None and err.errno != errno.EPIPE:
                ui.ferr.write(
                    b'abort: %s\n' % encoding.strtolocal(err.strerror)
                )
            ui.ferr.flush()
        # There's not much we can do about an I/O error here. So (possibly)
        # change the status code and move on.
        except OSError:
            status = -1

    return status


def run():
    """run the command in sys.argv"""
    try:
        initstdio()
        with tracing.log('parse args into request'):
            req = main_script.request(pycompat.sysargv[1:])

        status = dispatch(req)
        _silencestdio()
    except KeyboardInterrupt:
        # Catch early/late KeyboardInterrupt as last ditch. Here nothing will
        # be printed to console to avoid another IOError/KeyboardInterrupt.
        status = -1
    sys.exit(status & 255)


def initstdio():
    # stdio streams on Python 3 are io.TextIOWrapper instances proxying another
    # buffer. These streams will normalize \n to \r\n by default. Mercurial's
    # preferred mechanism for writing output (ui.write()) uses io.BufferedWriter
    # instances, which write to the underlying stdio file descriptor in binary
    # mode. ui.write() uses \n for line endings and no line ending normalization
    # is attempted through this interface. This "just works," even if the system
    # preferred line ending is not \n.
    #
    # But some parts of Mercurial (e.g. hooks) can still send data to sys.stdout
    # and sys.stderr. They will inherit the line ending normalization settings,
    # potentially causing e.g. \r\n to be emitted. Since emitting \n should
    # "just work," here we change the sys.* streams to disable line ending
    # normalization, ensuring compatibility with our ui type.

    if sys.stdout is not None:
        # write_through is new in Python 3.7.
        kwargs = {
            "newline": "\n",
            "line_buffering": bool(sys.stdout.line_buffering),
        }
        if hasattr(sys.stdout, "write_through"):
            # pytype: disable=attribute-error
            kwargs["write_through"] = sys.stdout.write_through
            # pytype: enable=attribute-error
        sys.stdout = io.TextIOWrapper(
            sys.stdout.buffer, sys.stdout.encoding, sys.stdout.errors, **kwargs
        )

    if sys.stderr is not None:
        kwargs = {
            "newline": "\n",
            "line_buffering": bool(sys.stderr.line_buffering),
        }
        if hasattr(sys.stderr, "write_through"):
            # pytype: disable=attribute-error
            kwargs["write_through"] = sys.stderr.write_through
            # pytype: enable=attribute-error
        sys.stderr = io.TextIOWrapper(
            sys.stderr.buffer, sys.stderr.encoding, sys.stderr.errors, **kwargs
        )

    if sys.stdin is not None:
        # No write_through on read-only stream.
        sys.stdin = io.TextIOWrapper(
            sys.stdin.buffer,
            sys.stdin.encoding,
            sys.stdin.errors,
            # None is universal newlines mode.
            newline=None,
            line_buffering=bool(sys.stdin.line_buffering),
        )


def _silencestdio():
    for fp in (sys.stdout, sys.stderr):
        if fp is None:
            continue
        # Check if the file is okay
        try:
            fp.flush()
            continue
        except OSError:
            pass
        # Otherwise mark it as closed to silence "Exception ignored in"
        # message emitted by the interpreter finalizer.
        try:
            fp.close()
        except OSError:
            pass


def _formatargs(args):
    return b' '.join(procutil.shellquote(a) for a in args)


def dispatch(req: main_script.request) -> int:
    """run the command specified in req.args; returns an integer status code"""
    err = None
    try:
        status = _rundispatch(req)
    except error.StdioError as e:
        err = e
        status = -1

    ret = _flushstdio(req.ui, err)
    if ret and not status:
        status = ret
    return status


def _rundispatch(req: main_script.request) -> int:
    # XXX figure out how to align old tracing and new tracing
    with (
        tracing.log('dispatch._rundispatch'),
        util.rust_tracing_span("dispatch._rundispatch"),
    ):
        if req.ferr:
            ferr = req.ferr
        elif req.ui:
            ferr = req.ui.ferr
        else:
            ferr = procutil.stderr

        try:
            if not req.ui:
                req.ui = uimod.ui.load()
            early_opts = args_util.early_parse_opts(req.ui, req.args)
            req.earlyoptions.update(early_opts)
            if req.earlyoptions[b'traceback']:
                req.ui.setconfig(b'ui', b'traceback', b'on', b'--traceback')
                encoding.enable_rust_backtrace()

            # set ui streams from the request
            if req.fin:
                req.ui.fin = req.fin
            if req.fout:
                req.ui.fout = req.fout
            if req.ferr:
                req.ui.ferr = req.ferr
            if req.fmsg:
                req.ui.fmsg = req.fmsg
        except error.Abort as inst:
            ferr.write(inst.format())
            return -1

        formattedargs = _formatargs(req.args)
        starttime = util.timer()
        ret = 1  # default of Python exit code on unhandled exception
        try:
            ret = _runcatch(req) or 0
        except error.ProgrammingError as inst:
            req.ui.error(_(b'** ProgrammingError: %s\n') % inst)
            if inst.hint:
                req.ui.error(_(b'** (%s)\n') % inst.hint)
            raise
        except KeyboardInterrupt as inst:
            try:
                if isinstance(inst, error.SignalInterrupt):
                    msg = _(b"killed!\n")
                else:
                    msg = _(b"interrupted!\n")
                req.ui.error(msg)
            except error.SignalInterrupt:
                # maybe pager would quit without consuming all the output, and
                # SIGPIPE was raised. we cannot print anything in this case.
                pass
            except BrokenPipeError:
                pass
            ret = -1
        finally:
            duration = util.timer() - starttime
            req.ui.flush()  # record blocked times
            if req.ui.logblockedtimes:
                req.ui._blockedtimes[b'command_duration'] = duration * 1000
                req.ui.log(
                    b'uiblocked',
                    b'ui blocked ms\n',
                    **pycompat.strkwargs(req.ui._blockedtimes),
                )
            return_code = ret & 255
            req.ui.log(
                b"commandfinish",
                b"%s exited %d after %0.2f seconds\n",
                formattedargs,
                return_code,
                duration,
                return_code=return_code,
                duration=duration,
                canonical_command=req.canonical_command,
            )
            try:
                req._runexithandlers()
            except:  # exiting, so no re-raises
                ret = ret or -1
            # do flush again since ui.log() and exit handlers may write to ui
            req.ui.flush()
        return ret


def _runcatch(req):
    with tracing.log('dispatch._runcatch'):

        def catchterm(*args):
            raise error.SignalInterrupt

        ui = req.ui
        try:
            for name in 'SIGBREAK', 'SIGHUP', 'SIGTERM':
                num = getattr(signal, name, None)
                if num:
                    signal.signal(num, catchterm)
        except ValueError:
            pass  # happens if called in a thread

        def _runcatchfunc():
            realcmd = None
            try:
                cmdargs = fancyopts.fancyopts(
                    req.args[:], cmd_impls.global_opts, {}
                )
                cmd = cmdargs[0]
                aliases, entry = cmd_finder.find_cmd(
                    cmd,
                    tables.command_table,
                    False,
                )
                realcmd = aliases[0]
            except (
                error.UnknownCommand,
                error.AmbiguousCommand,
                IndexError,
                getopt.GetoptError,
            ):
                # Don't handle this here. We know the command is
                # invalid, but all we're worried about for now is that
                # it's not a command that server operators expect to
                # be safe to offer to users in a sandbox.
                pass
            if realcmd == b'serve' and b'--stdio' in cmdargs:
                # We want to constrain 'hg serve --stdio' instances pretty
                # closely, as many shared-ssh access tools want to grant
                # access to run *only* 'hg -R $repo serve --stdio'. We
                # restrict to exactly that set of arguments, and prohibit
                # any repo name that starts with '--' to prevent
                # shenanigans wherein a user does something like pass
                # --debugger or --config=ui.debugger=1 as a repo
                # name. This used to actually run the debugger.
                nbargs = 4
                hashiddenaccess = b'--hidden' in cmdargs
                if hashiddenaccess:
                    nbargs += 1
                if (
                    len(req.args) != nbargs
                    or req.args[0] != b'-R'
                    or req.args[1].startswith(b'--')
                    or req.args[2] != b'serve'
                    or req.args[3] != b'--stdio'
                    or hashiddenaccess
                    and req.args[4] != b'--hidden'
                ):
                    raise error.Abort(
                        _(b'potentially unsafe serve --stdio invocation: %s')
                        % (stringutil.pprint(req.args),)
                    )

            try:
                debugger = b'pdb'
                debugtrace = {b'pdb': pdb.set_trace}
                debugmortem = {b'pdb': pdb.post_mortem}

                # read --config-file and --config before doing anything else
                # (e.g. to change trust settings for reading .hg/hgrc).

                # cmdargs may not have been initialized here (in the case of an
                # error), so use pycompat.sysargv instead.
                file_cfgs = args_util.parse_config_files_opts(
                    req.ui, pycompat.sysargv, req.earlyoptions[b'config_file']
                )
                cfgs = args_util.parse_config_opts(
                    req.ui, req.earlyoptions[b'config']
                )

                if req.repo:
                    # copy configs that were passed on the cmdline (--config) to
                    # the repo ui
                    for sec, name, val, source in file_cfgs:
                        req.repo.ui.setconfig(sec, name, val, source=source)

                    for sec, name, val in cfgs:
                        req.repo.ui.setconfig(
                            sec, name, val, source=b'--config'
                        )

                # developer config: ui.debugger
                debugger = ui.config(b"ui", b"debugger")
                debugmod = pdb
                if not debugger or ui.plain():
                    # if we are in HGPLAIN mode, then disable custom debugging
                    debugger = b'pdb'
                elif req.earlyoptions[b'debugger']:
                    # This import can be slow for fancy debuggers, so only
                    # do it when absolutely necessary, i.e. when actual
                    # debugging has been requested
                    with demandimport.deactivated():
                        try:
                            debugmod = __import__(pycompat.sysstr(debugger))
                        except ImportError:
                            pass  # Leave debugmod = pdb

                debugtrace[debugger] = debugmod.set_trace
                debugmortem[debugger] = debugmod.post_mortem

                # enter the debugger before command execution
                if req.earlyoptions[b'debugger']:
                    ui.warn(
                        _(
                            b"entering debugger - "
                            b"type c to continue starting hg or h for help\n"
                        )
                    )

                    if (
                        debugger != b'pdb'
                        and debugtrace[debugger] == debugtrace[b'pdb']
                    ):
                        ui.warn(
                            _(
                                b"%s debugger specified "
                                b"but its module was not found\n"
                            )
                            % debugger
                        )
                    with demandimport.deactivated():
                        debugtrace[debugger]()
                try:
                    return _dispatch(req)
                finally:
                    ui.flush()
            except:  # re-raises
                # enter the debugger when we hit an exception
                if req.earlyoptions[b'debugger']:
                    traceback.print_exc()
                    debugmortem[debugger](sys.exc_info()[2])
                raise

        return _callcatch(ui, _runcatchfunc)


def _callcatch(ui, func):
    """like scmutil.callcatch but handles more high-level exceptions about
    config parsing and commands. besides, use handlecommandexception to handle
    uncaught exceptions.
    """
    detailed_exit_code = -1
    try:
        return scmutil.callcatch(ui, func)
    except error.AmbiguousCommand as inst:
        detailed_exit_code = 10
        ui.warn(
            _(b"hg: command '%s' is ambiguous:\n    %s\n")
            % (inst.prefix, b" ".join(inst.matches))
        )
    except error.CommandError as inst:
        detailed_exit_code = 10
        if inst.command:
            ui.pager(b'help')
            msgbytes = pycompat.bytestr(inst.message)
            ui.warn(_(b"hg %s: %s\n") % (inst.command, msgbytes))
            commands.help_(ui, inst.command, full=False, command=True)
        else:
            ui.warn(_(b"hg: %s\n") % inst.message)
            ui.warn(_(b"(use 'hg help -v' for a list of global options)\n"))
    except error.UnknownCommand as inst:
        detailed_exit_code = 10
        nocmdmsg = _(b"hg: unknown command '%s'\n") % inst.command
        try:
            # check if the command is in a disabled extension
            # (but don't check for extensions themselves)
            formatted = help.formattedhelp(ui, inst.command, unknowncmd=True)
            ui.warn(nocmdmsg)
            ui.write(formatted)
        except (error.UnknownCommand, error.Abort):
            suggested = False
            if inst.all_commands:
                sim = error.getsimilar(inst.all_commands, inst.command)
                if sim:
                    ui.warn(nocmdmsg)
                    ui.warn(b"(%s)\n" % error.similarity_hint(sim))
                    suggested = True
            if not suggested:
                ui.warn(nocmdmsg)
                ui.warn(_(b"(use 'hg help' for a list of commands)\n"))
    except OSError:
        raise
    except KeyboardInterrupt:
        raise
    except:  # probably re-raises
        if not handlecommandexception(ui):
            raise

    if ui.configbool(b'ui', b'detailed-exit-code'):
        return detailed_exit_code
    else:
        return -1


def aliasargs(fn, givenargs):
    args = []
    # only care about alias 'args', ignore 'args' set by extensions.wrapfunction
    if not hasattr(fn, '_origfunc'):
        args = getattr(fn, 'args', args)
    if args:
        cmd = b' '.join(map(procutil.shellquote, args))

        nums = []

        def replacer(m):
            num = int(m.group(1)) - 1
            nums.append(num)
            if num < len(givenargs):
                return givenargs[num]
            raise error.InputError(_(b'too few arguments for command alias'))

        cmd = re.sub(br'\$(\d+|\$)', replacer, cmd)
        givenargs = [x for i, x in enumerate(givenargs) if i not in nums]
        args = pycompat.shlexsplit(cmd)
    return args + givenargs


def aliasinterpolate(name, args, cmd):
    """interpolate args into cmd for shell aliases

    This also handles $0, $@ and "$@".
    """
    # util.interpolate can't deal with "$@" (with quotes) because it's only
    # built to match prefix + patterns.
    replacemap = {b'$%d' % (i + 1): arg for i, arg in enumerate(args)}
    replacemap[b'$0'] = name
    replacemap[b'$$'] = b'$'
    replacemap[b'$@'] = b' '.join(args)
    # Typical Unix shells interpolate "$@" (with quotes) as all the positional
    # parameters, separated out into words. Emulate the same behavior here by
    # quoting the arguments individually. POSIX shells will then typically
    # tokenize each argument into exactly one word.
    replacemap[b'"$@"'] = b' '.join(procutil.shellquote(arg) for arg in args)
    # escape '\$' for regex
    regex = b'|'.join(replacemap.keys()).replace(b'$', br'\$')
    r = re.compile(regex)
    return r.sub(lambda x: replacemap[x.group()], cmd)


class cmdalias:
    def __init__(self, ui, name, definition, cmdtable, source):
        self.name = self.cmd = name
        self.cmdname = b''
        self.definition = definition
        self.fn = None
        self.givenargs = []
        self.opts = []
        self.help = b''
        self.badalias = None
        self.unknowncmd = False
        self.source = source

        try:
            aliases, entry = cmd_finder.find_cmd(self.name, cmdtable)
            for alias, e in cmdtable.items():
                if e is entry:
                    self.cmd = alias
                    break
            self.shadows = True
        except error.UnknownCommand:
            self.shadows = False

        if not self.definition:
            self.badalias = _(b"no definition for alias '%s'") % self.name
            return

        if self.definition.startswith(b'!'):
            shdef = self.definition[1:]
            self.shell = True

            def fn(ui, *args):
                env = {b'HG_ARGS': b' '.join((self.name,) + args)}

                def _checkvar(m):
                    if m.groups()[0] == b'$':
                        return m.group()
                    elif int(m.groups()[0]) <= len(args):
                        return m.group()
                    else:
                        ui.debug(
                            b"No argument found for substitution "
                            b"of %i variable in alias '%s' definition.\n"
                            % (int(m.groups()[0]), self.name)
                        )
                        return b''

                cmd = re.sub(br'\$(\d+|\$)', _checkvar, shdef)
                cmd = aliasinterpolate(self.name, args, cmd)
                return ui.system(
                    cmd, environ=env, blockedtag=b'alias_%s' % self.name
                )

            self.fn = fn
            self.alias = True
            self._populatehelp(ui, name, shdef, self.fn)
            return

        try:
            args = pycompat.shlexsplit(self.definition)
        except ValueError as inst:
            self.badalias = _(b"error in definition for alias '%s': %s") % (
                self.name,
                stringutil.forcebytestr(inst),
            )
            return
        earlyopts, args = _earlysplitopts(args)
        if earlyopts:
            self.badalias = _(
                b"error in definition for alias '%s': %s may "
                b"only be given on the command line"
            ) % (self.name, b'/'.join(pycompat.ziplist(*earlyopts)[0]))
            return
        self.cmdname = cmd = args.pop(0)
        self.givenargs = args

        try:
            tableentry = cmd_finder.find_cmd(cmd, cmdtable, False)[1]
            if len(tableentry) > 2:
                self.fn, self.opts, cmdhelp = tableentry
            else:
                self.fn, self.opts = tableentry
                cmdhelp = None

            self.alias = True
            self._populatehelp(ui, name, cmd, self.fn, cmdhelp)

        except error.UnknownCommand:
            self.badalias = _(
                b"alias '%s' resolves to unknown command '%s'"
            ) % (
                self.name,
                cmd,
            )
            self.unknowncmd = True
        except error.AmbiguousCommand:
            self.badalias = _(
                b"alias '%s' resolves to ambiguous command '%s'"
            ) % (
                self.name,
                cmd,
            )

    def _populatehelp(self, ui, name, cmd, fn, defaulthelp=None):
        # confine strings to be passed to i18n.gettext()
        cfg = {}
        for k in (b'doc', b'help', b'category'):
            v = ui.config(b'alias', b'%s:%s' % (name, k), None)
            if v is None:
                continue
            if not encoding.isasciistr(v):
                self.badalias = _(
                    b"non-ASCII character in alias definition '%s:%s'"
                ) % (name, k)
                return
            cfg[k] = v

        self.help = cfg.get(b'help', defaulthelp or b'')
        if self.help and self.help.startswith(b"hg " + cmd):
            # drop prefix in old-style help lines so hg shows the alias
            self.help = self.help[4 + len(cmd) :]

        self.owndoc = b'doc' in cfg
        doc = cfg.get(b'doc', pycompat.getdoc(fn))
        if doc is not None:
            doc = pycompat.sysstr(doc)
        self.__doc__ = doc

        self.helpcategory = cfg.get(
            b'category', registrar.command.CATEGORY_NONE
        )

    @property
    def args(self):
        args = pycompat.maplist(util.expandpath, self.givenargs)
        return aliasargs(self.fn, args)

    def __getattr__(self, name):
        adefaults = {
            'norepo': True,
            'intents': set(),
            'optionalrepo': False,
            'inferrepo': False,
            'need_dispatcher': False,
        }
        if name not in adefaults:
            raise AttributeError(name)
        if self.badalias or hasattr(self, 'shell'):
            return adefaults[name]
        return getattr(self.fn, name)

    def __call__(self, ui, *args, **opts):
        if self.badalias:
            hint = None
            if self.unknowncmd:
                try:
                    # check if the command is in a disabled extension
                    cmd, ext = extensions.disabledcmd(ui, self.cmdname)[:2]
                    hint = _(b"'%s' is provided by '%s' extension") % (cmd, ext)
                except error.UnknownCommand:
                    pass
            raise error.ConfigError(self.badalias, hint=hint)
        if self.shadows:
            ui.debug(
                b"alias '%s' shadows command '%s'\n" % (self.name, self.cmdname)
            )

        ui.log(
            b'commandalias',
            b"alias '%s' expands to '%s'\n",
            self.name,
            self.definition,
        )
        if hasattr(self, 'shell'):
            return self.fn(ui, *args, **opts)
        else:
            try:
                return util.checksignature(self.fn)(ui, *args, **opts)
            except error.SignatureError:
                args = b' '.join([self.cmdname] + self.args)
                ui.debug(b"alias '%s' expands to '%s'\n" % (self.name, args))
                raise


class lazyaliasentry:
    """like a typical command entry (func, opts, help), but is lazy"""

    def __init__(self, ui, name, definition, cmdtable, source):
        self.ui = ui
        self.name = name
        self.definition = definition
        self.cmdtable = cmdtable.copy()
        self.source = source
        self.alias = True

    @util.propertycache
    def _aliasdef(self):
        return cmdalias(
            self.ui, self.name, self.definition, self.cmdtable, self.source
        )

    def __getitem__(self, n):
        aliasdef = self._aliasdef
        if n == 0:
            return aliasdef
        elif n == 1:
            return aliasdef.opts
        elif n == 2:
            return aliasdef.help
        else:
            raise IndexError

    def __iter__(self):
        for i in range(3):
            yield self[i]

    def __len__(self):
        return 3


def addaliases(ui, cmdtable):
    # aliases are processed after extensions have been loaded, so they
    # may use extension commands. Aliases can also use other alias definitions,
    # but only if they have been defined prior to the current definition.
    for alias, definition in ui.configitems(b'alias', ignoresub=True):
        try:
            if cmdtable[alias].definition == definition:
                continue
        except (KeyError, AttributeError):
            # definition might not exist or it might not be a cmdalias
            pass

        source = ui.configsource(b'alias', alias)
        entry = lazyaliasentry(ui, alias, definition, cmdtable, source)
        cmdtable[alias] = entry


def _parse(ui, args):
    options = {}
    cmdoptions = {}

    try:
        args = fancyopts.fancyopts(args, cmd_impls.global_opts, options)
    except getopt.GetoptError as inst:
        raise error.CommandError(None, stringutil.forcebytestr(inst))

    if args:
        cmd, args = args[0], args[1:]
        aliases, entry = cmd_finder.find_cmd(
            cmd, tables.command_table, ui.configbool(b"ui", b"strict")
        )
        cmd = aliases[0]
        args = aliasargs(entry[0], args)
        defaults = ui.config(b"defaults", cmd)
        if defaults:
            args = (
                pycompat.maplist(util.expandpath, pycompat.shlexsplit(defaults))
                + args
            )
        c = list(entry[1])
    else:
        cmd = None
        c = []

    def global_opt_to_fancy_opt(opt_name: bytes) -> bytes:
        # fancyopts() does this transform on `options`, but globalopts uses a
        # '-', so that it is displayed in the help and accepted as input that
        # way.
        return opt_name.replace(b'-', b'_')

    # combine global options into local
    for o in cmd_impls.global_opts:
        name = global_opt_to_fancy_opt(o[1])

        # The fancyopts name is needed for `options`, but the original name
        # needs to be used in the second element here, or the parsing for the
        # command verb fails, saying the command has no such option.
        c.append((o[0], o[1], options[name], o[3]))

    try:
        args = fancyopts.fancyopts(args, c, cmdoptions, gnu=True)
    except getopt.GetoptError as inst:
        raise error.CommandError(cmd, stringutil.forcebytestr(inst))

    # separate global options back out
    for o in cmd_impls.global_opts:
        n = global_opt_to_fancy_opt(o[1])
        options[n] = cmdoptions[n]
        del cmdoptions[n]

    return (cmd, cmd and entry[0] or None, args, options, cmdoptions)


def _earlysplitopts(args):
    """Split args into a list of possible early options and remainder args"""
    shortoptions = b'R:'
    # TODO: perhaps 'debugger' should be included
    longoptions = [
        b'cwd=',
        b'repository=',
        b'repo=',
        b'config=',
        b'config-file=',
    ]
    return fancyopts.earlygetopt(
        args, shortoptions, longoptions, gnu=True, keepsep=True
    )


def runcommand(lui, repo, cmd, fullargs, ui, options, d, cmdpats, cmdoptions):
    # run pre-hook, and abort if it fails
    hook.hook(
        lui,
        repo,
        b"pre-%s" % cmd,
        True,
        args=b" ".join(fullargs),
        pats=cmdpats,
        opts=cmdoptions,
    )
    try:
        ret = _runcommand(ui, options, cmd, d)
        # run post-hook, passing command result
        hook.hook(
            lui,
            repo,
            b"post-%s" % cmd,
            False,
            args=b" ".join(fullargs),
            result=ret,
            pats=cmdpats,
            opts=cmdoptions,
        )
    except Exception:
        # run failure hook and re-raise
        hook.hook(
            lui,
            repo,
            b"fail-%s" % cmd,
            False,
            args=b" ".join(fullargs),
            pats=cmdpats,
            opts=cmdoptions,
        )
        raise
    return ret


def _checkshellalias(lui, ui, args):
    """Return the function to run the shell alias, if it is required"""
    options = {}

    try:
        args = fancyopts.fancyopts(args, cmd_impls.global_opts, options)
    except getopt.GetoptError:
        return

    if not args:
        return

    cmdtable = tables.command_table

    cmd = args[0]
    try:
        strict = ui.configbool(b"ui", b"strict")
        aliases, entry = cmd_finder.find_cmd(cmd, cmdtable, strict)
    except (error.AmbiguousCommand, error.UnknownCommand):
        return

    cmd = aliases[0]
    fn = entry[0]

    if cmd and hasattr(fn, 'shell'):
        # shell alias shouldn't receive early options which are consumed by hg
        _earlyopts, args = _earlysplitopts(args)
        d = lambda: fn(ui, *args[1:])
        return lambda: runcommand(
            lui, None, cmd, args[:1], ui, options, d, [], {}
        )


def _dispatch(req):
    # check for cwd
    old_cwd = None
    cwd = req.earlyoptions[b'cwd']
    try:
        if cwd:
            old_cwd = local_util.get_cwd()
            os.chdir(cwd)
        return _dispatch_post_cwd(req)
    finally:
        if old_cwd is not None:
            os.chdir(old_cwd)


def _dispatch_post_cwd(req):
    args = req.args
    ui = req.ui

    rpath = req.earlyoptions[b'repository']
    path, lui = local_util.get_local(ui, rpath)

    uis = {ui, lui}

    if req.repo:
        uis.add(req.repo.ui)

    if (
        req.earlyoptions[b'verbose']
        or req.earlyoptions[b'debug']
        or req.earlyoptions[b'quiet']
    ):
        for opt in (b'verbose', b'debug', b'quiet'):
            val = pycompat.bytestr(bool(req.earlyoptions[opt]))
            for ui_ in uis:
                ui_.setconfig(b'ui', opt, val, b'--' + opt)

    early_profile = req.earlyoptions[b'profile']

    if early_profile and early_profile:
        for ui_ in uis:
            ui_.setconfig(b'profiling', b'enabled', b'true', b'--profile')
    elif early_profile is not None and not early_profile:
        # Check for it being set already, so that we don't pollute the config
        # with this when using chg in the very common case that it's not
        # enabled.
        if lui.configbool(b'profiling', b'enabled'):
            # Only do this on lui so that `chg foo` with a user config setting
            # profiling.enabled=1 still shows profiling information (chg will
            # specify `--no-profile` when `hg serve` is starting up, we don't
            # want that to propagate to every later invocation).
            lui.setconfig(b'profiling', b'enabled', b'false', b'--no-profile')

    profile = lui.configbool(b'profiling', b'enabled')
    with profiling.profile(lui, enabled=profile) as profiler:
        # Configure extensions in phases: uisetup, extsetup, cmdtable, and
        # reposetup
        extensions.loadall(lui)
        # Propagate any changes to lui.__class__ by extensions
        ui.__class__ = lui.__class__

        # (uisetup and extsetup are handled in extensions.loadall)

        # (reposetup is handled in repo.factory.repository)

        addaliases(lui, tables.command_table)

        # All aliases and commands are completely defined, now.
        # Check abbreviation/ambiguity of shell alias.
        shellaliasfn = _checkshellalias(lui, ui, args)
        if shellaliasfn:
            # no additional configs will be set, set up the ui instances
            for ui_ in uis:
                extensions.populateui(ui_)
            return shellaliasfn()

        # check for fallback encoding
        fallback = lui.config(b'ui', b'fallbackencoding')
        if fallback:
            encoding.fallbackencoding = fallback

        fullargs = args
        try:
            cmd, func, args, options, cmdoptions = _parse(lui, args)
        except error.CommandError as e:
            cause = e.__context__
            if isinstance(cause, getopt.GetoptError):
                if cause.opt and "config".startswith(cause.opt):
                    # pycompat._getoptbwrapper() decodes bytes with latin-1
                    opt = cause.opt.encode('latin-1')

                    # pytype seems to merge all the types in each tuple index,
                    # but o[1] is always bytes.
                    all_long = typing.cast(
                        set[bytes], {o[1] for o in cmd_impls.global_opts}
                    )
                    possible = [o for o in all_long if o.startswith(opt)]

                    if len(possible) != 1:
                        raise error.InputError(
                            _(b"option --config may not be abbreviated")
                        )
            raise

        # store the canonical command name in request object for later access
        req.canonical_command = cmd

        if options[b"config"] != req.earlyoptions[b"config"]:
            raise error.InputError(_(b"option --config may not be abbreviated"))
        if options[b"config_file"] != req.earlyoptions[b"config_file"]:
            raise error.InputError(
                _(b"option --config-file may not be abbreviated")
            )
        if options[b"cwd"] != req.earlyoptions[b"cwd"]:
            raise error.InputError(_(b"option --cwd may not be abbreviated"))
        if options[b"repository"] != req.earlyoptions[b"repository"]:
            raise error.InputError(
                _(
                    b"option -R has to be separated from other options (e.g. not "
                    b"-qR) and --repository may only be abbreviated as --repo"
                )
            )
        if options[b"debugger"] != req.earlyoptions[b"debugger"]:
            raise error.InputError(
                _(b"option --debugger may not be abbreviated")
            )
        # don't validate --profile/--traceback, which can be enabled from now

        if options[b"encoding"]:
            encoding.encoding = options[b"encoding"]
        if options[b"encodingmode"]:
            encoding.encodingmode = options[b"encodingmode"]
        if options[b"time"]:

            def get_times():
                t = os.times()
                if t[4] == 0.0:
                    # Windows leaves this as zero, so use time.perf_counter()
                    t = (t[0], t[1], t[2], t[3], util.timer())
                return t

            s = get_times()

            def print_time():
                t = get_times()
                ui.warn(
                    _(b"time: real %.3f secs (user %.3f+%.3f sys %.3f+%.3f)\n")
                    % (
                        t[4] - s[4],
                        t[0] - s[0],
                        t[2] - s[2],
                        t[1] - s[1],
                        t[3] - s[3],
                    )
                )

            ui.atexit(print_time)
        if options[b"profile"]:
            profiler.start()

        # if abbreviated version of this were used, take them in account, now
        if options[b'verbose'] or options[b'debug'] or options[b'quiet']:
            for opt in (b'verbose', b'debug', b'quiet'):
                if options[opt] == req.earlyoptions[opt]:
                    continue
                val = pycompat.bytestr(bool(options[opt]))
                for ui_ in uis:
                    ui_.setconfig(b'ui', opt, val, b'--' + opt)

        if options[b'traceback']:
            for ui_ in uis:
                ui_.setconfig(b'ui', b'traceback', b'on', b'--traceback')

        if options[b'noninteractive']:
            for ui_ in uis:
                ui_.setconfig(b'ui', b'interactive', b'off', b'-y')

        if cmdoptions.get(b'insecure', False):
            for ui_ in uis:
                ui_.insecureconnections = True

        # setup color handling before pager, because setting up pager
        # might cause incorrect console information
        coloropt = options[b'color']
        for ui_ in uis:
            if coloropt:
                ui_.setconfig(b'ui', b'color', coloropt, b'--color')
            color.setup(ui_)

        if stringutil.parsebool(options[b'pager']):
            # ui.pager() expects 'internal-always-' prefix in this case
            ui.pager(b'internal-always-' + cmd)
        elif options[b'pager'] != b'auto':
            for ui_ in uis:
                ui_.disablepager()

        # configs are fully loaded, set up the ui instances
        for ui_ in uis:
            extensions.populateui(ui_)

        if options[b'version']:
            return commands.version_(ui)
        if options[b'help']:
            return commands.help_(ui, cmd, command=cmd is not None)
        elif not cmd:
            return commands.help_(ui, b'shortlist')

        repo = None
        cmdpats = args[:]
        assert func is not None  # help out pytype
        if not func.norepo:
            # use the repo from the request only if we don't have -R
            if not rpath and not req.earlyoptions[b'cwd']:
                repo = req.repo

            if repo:
                # set the descriptors of the repo ui to those of ui
                repo.ui.fin = ui.fin
                repo.ui.fout = ui.fout
                repo.ui.ferr = ui.ferr
                repo.ui.fmsg = ui.fmsg
            else:
                try:
                    repo = repo_factory.repository(
                        ui,
                        path=path,
                        presetupfuncs=req.prereposetups,
                        intents=func.intents,
                    )
                    if not repo.local():
                        raise error.InputError(
                            _(b"repository '%s' is not local") % path
                        )
                    repo.ui.setconfig(
                        b"bundle", b"mainreporoot", repo.root, b'repo'
                    )
                except error.RequirementError:
                    raise
                except error.RepoError:
                    if rpath:  # invalid -R path
                        raise
                    if not func.optionalrepo:
                        if func.inferrepo and args and not path:
                            # try to infer -R from command args
                            repos = pycompat.maplist(repo_utils.find_repo, args)
                            guess = repos[0]
                            if guess and repos.count(guess) == len(repos):
                                req.args = [b'--repository', guess] + fullargs
                                req.earlyoptions[b'repository'] = guess
                                return _dispatch(req)
                        if not path:
                            raise error.InputError(
                                _(
                                    b"no repository found in"
                                    b" '%s' (.hg not found)"
                                )
                                % encoding.getcwd()
                            )
                        raise
            if repo:
                ui = repo.ui
                if options[b'hidden']:
                    repo = repo.unfiltered()
            args.insert(0, repo)
        elif rpath:
            ui.warn(_(b"warning: --repository ignored\n"))

        msg = _formatargs(fullargs)
        ui.log(b"command", b'%s\n', msg)
        strcmdopt = pycompat.strkwargs(cmdoptions)
        if func.need_dispatcher:
            strcmdopt["__dispatch__"] = dispatch
        d = lambda: util.checksignature(func)(ui, *args, **strcmdopt)
        try:
            return runcommand(
                lui, repo, cmd, fullargs, ui, options, d, cmdpats, cmdoptions
            )
        finally:
            if repo and repo != req.repo:
                repo.close()


def _runcommand(ui, options, cmd, cmdfunc):
    """Run a command function, possibly with profiling enabled."""
    try:
        msg = "Running %s command" % cmd
        with tracing.log(msg), util.rust_tracing_span(msg):
            return cmdfunc()
    except error.SignatureError:
        raise error.CommandError(cmd, _(b'invalid arguments'))


def _exceptionwarning(ui):
    """Produce a warning message for the current active exception"""

    # For compatibility checking, we discard the portion of the hg
    # version after the + on the assumption that if a "normal
    # user" is running a build with a + in it the packager
    # probably built from fairly close to a tag and anyone with a
    # 'make local' copy of hg (where the version number can be out
    # of date) will be clueful enough to notice the implausible
    # version number and try updating.
    ct = util.versiontuple(n=2)
    worst = None, ct, b'', b''
    if ui.config(b'ui', b'supportcontact') is None:
        for name, mod in extensions.extensions():
            # 'testedwith' should be bytes, but not all extensions are ported
            # to py3 and we don't want UnicodeException because of that.
            testedwith = stringutil.forcebytestr(
                getattr(mod, 'testedwith', b'')
            )
            version = extensions.moduleversion(mod)
            report = getattr(mod, 'buglink', _(b'the extension author.'))
            if not testedwith.strip():
                # We found an untested extension. It's likely the culprit.
                worst = name, b'unknown', report, version
                break

            # Never blame on extensions bundled with Mercurial.
            if extensions.ismoduleinternal(mod):
                continue

            tested = [util.versiontuple(t, 2) for t in testedwith.split()]
            if ct in tested:
                continue

            lower = [t for t in tested if t < ct]
            nearest = max(lower or tested)
            if worst[0] is None or nearest < worst[1]:
                worst = name, nearest, report, version
    if worst[0] is not None:
        name, testedwith, report, version = worst
        if not isinstance(testedwith, (bytes, str)):
            testedwith = b'.'.join(
                [stringutil.forcebytestr(c) for c in testedwith]
            )
        extver = version or _(b"(version N/A)")
        warning = _(
            b'** Unknown exception encountered with '
            b'possibly-broken third-party extension "%s" %s\n'
            b'** which supports versions %s of Mercurial.\n'
            b'** Please disable "%s" and try your action again.\n'
            b'** If that fixes the bug please report it to %s\n'
        ) % (name, extver, testedwith, name, stringutil.forcebytestr(report))
    else:
        bugtracker = ui.config(b'ui', b'supportcontact')
        if bugtracker is None:
            bugtracker = _(
                b"https://foss.heptapod.net/mercurial/mercurial-devel/-/issues"
            )
        warning = (
            _(
                b"** unknown exception encountered, "
                b"please report by visiting\n** "
            )
            + bugtracker
            + b'\n'
        )
    sysversion = pycompat.sysbytes(sys.version).replace(b'\n', b'')

    def ext_with_ver(x):
        ext = x[0]
        ver = extensions.moduleversion(x[1])
        if ver:
            ext += b' ' + ver
        return ext

    warning += (
        (_(b"** Python %s\n") % sysversion)
        + (_(b"** Mercurial Distributed SCM (version %s)\n") % util.version())
        + (
            _(b"** Extensions loaded: %s\n")
            % b", ".join(
                [ext_with_ver(x) for x in sorted(extensions.extensions())]
            )
        )
    )
    return warning


def handlecommandexception(ui):
    """Produce a warning message for broken commands

    Called when handling an exception; the exception is reraised if
    this function returns False, ignored otherwise.
    """
    warning = _exceptionwarning(ui)
    ui.log(
        b"commandexception",
        b"%s\n%s\n",
        warning,
        pycompat.sysbytes(traceback.format_exc()),
    )
    ui.warn(warning)
    return False  # re-raise the exception
