diff mercurial/dispatch.py @ 52758:25b344f2aeef

dispatch: add support for the `--config-file` global option Previously, loading a one-off config file for a command could be tricky- only the system level `*.rc` file processing supported scanning a directory of files (and the current user may not have the permission to modify those directories). Setting `HGRCPATH=...` worked, but also disabled the normal processing of all system level and user level config files, and there was no way to add the normal path processing into `HGRCPATH`. Therefore, there was no easy way to append a config file to the normally processed config. Some programs have taken to rewriting config into the repo level hgrc, and removing it when they're done. This makes that hack unnecessary. Some config options (like `[auth]`) shouldn't be passed on the command line for security reasons, so the existing `--config` isn't sufficient. The config items here are handled very similarly to `--config` items, namely any item supercedes the same item in the system, user, and repo the configs. The files are processed in the order they were specified, and any `--config` item will override an item in one of these files, regardless of the order they were specified on the command line. I don't like having to disable `ui.detailed-exit-code` in `test-config.t` to appease chg, but since the (bad?) behavior also occurs with `--config`, whatever is going on was existing behavior and not a problem with this change in particular. I don't see an obvious reason for this behavior difference, and don't want to hold this up for something that nobody seems to have complained about. Also note that when there are certain parsing errors (e.g. `hg --confi "foo.bar=baz"` in `test-globalopts.t` tripped this), the code still plows through where `_parse_config_files()` is called in `dispatch.py`, but `cmdargs` isn't initialized. I'd expect a failure like that to bail out earlier, but just avoid the problem by using `pycompat.sysargv`.
author Matt Harbison <matt_harbison@yahoo.com>
date Wed, 29 Jan 2025 16:09:06 -0500
parents 1ccbca64610a
children 8780d5707812
line wrap: on
line diff
--- a/mercurial/dispatch.py	Wed Jan 29 16:04:39 2025 -0500
+++ b/mercurial/dispatch.py	Wed Jan 29 16:09:06 2025 -0500
@@ -17,6 +17,9 @@
 import sys
 import traceback
 
+from typing import (
+    Iterable,
+)
 
 from .i18n import _
 
@@ -26,6 +29,7 @@
     cmdutil,
     color,
     commands,
+    config as configmod,
     demandimport,
     encoding,
     error,
@@ -387,13 +391,22 @@
                 debugtrace = {b'pdb': pdb.set_trace}
                 debugmortem = {b'pdb': pdb.post_mortem}
 
-                # read --config before doing anything else
-                # (e.g. to change trust settings for reading .hg/hgrc)
+                # 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 = _parse_config_files(
+                    req.ui, pycompat.sysargv, req.earlyoptions[b'config_file']
+                )
                 cfgs = _parseconfig(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'
@@ -875,6 +888,48 @@
     return configs
 
 
+def _parse_config_files(
+    ui, cmdargs: list[bytes], config_files: Iterable[bytes]
+) -> list[tuple[bytes, bytes, bytes, bytes]]:
+    """parse the --config-file options from the command line
+
+    A list of tuples containing (section, name, value, source) is returned,
+    in the order they were read.
+    """
+
+    configs: list[tuple[bytes, bytes, bytes, bytes]] = []
+
+    cfg = configmod.config()
+
+    for file in config_files:
+        try:
+            cfg.read(file)
+        except error.ConfigError as e:
+            raise error.InputError(
+                _(b'invalid --config-file content at %s') % e.location,
+                hint=e.message,
+            )
+        except FileNotFoundError:
+            hint = None
+            if b'--cwd' in cmdargs:
+                hint = _(b"this file is resolved before --cwd is processed")
+
+            raise error.InputError(
+                _(b'missing file "%s" for --config-file') % file, hint=hint
+            )
+
+    for section in cfg.sections():
+        for item in cfg.items(section):
+            name = item[0]
+            value = item[1]
+            src = cfg.source(section, name)
+
+            ui.setconfig(section, name, value, src)
+            configs.append((section, name, value, src))
+
+    return configs
+
+
 def _earlyparseopts(ui, args):
     options = {}
     fancyopts.fancyopts(
@@ -892,7 +947,13 @@
     """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=']
+    longoptions = [
+        b'cwd=',
+        b'repository=',
+        b'repo=',
+        b'config=',
+        b'config-file=',
+    ]
     return fancyopts.earlygetopt(
         args, shortoptions, longoptions, gnu=True, keepsep=True
     )
@@ -1097,6 +1158,10 @@
 
         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"]: