diff mbox series

[v2,3/3] config: allow overriding of global and system configuration

Message ID 272a3b31aa73da8d65b04e647b1b9ca860f4e783.1617975637.git.ps@pks.im (mailing list archive)
State Superseded
Headers show
Series config: allow overriding global/system config | expand

Commit Message

Patrick Steinhardt April 9, 2021, 1:43 p.m. UTC
In order to have git run in a fully controlled environment without any
misconfiguration, it may be desirable for users or scripts to override
global- and system-level configuration files. We already have a way of
doing this, which is to unset both HOME and XDG_CONFIG_HOME environment
variables and to set `GIT_CONFIG_NOGLOBAL=true`. This is quite kludgy,
and unsetting the first two variables likely has an impact on other
executables spawned by such a script.

The obvious way to fix this would be to introduce `GIT_CONFIG_NOSYSTEM`
as an equivalent to `GIT_CONFIG_NOGLOBAL`. But in the past, it has
turned out that this design is inflexible: we cannot test system-level
parsing of the git configuration in our test harness because there is no
way to change its location, so all tests run with `GIT_CONFIG_NOSYSTEM`
set.

Instead of doing the same mistake with `GIT_CONFIG_NOGLOBAL`, introduce
two new variables `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM`:

    - If unset, git continues to use the usual locations.

    - If set to a specific path, we skip reading the normal
      configuration files and instead take the path. This path must
      exist and be readable to ensure that the user didn't typo.

    - If set to `/dev/null`, we do not load either global- or
      system-level configuration at all.

This implements the usecase where we want to execute code in a sanitized
environment without any potential misconfigurations via `/dev/null`, but
is more flexible and allows for more usecases than simply adding
`GIT_CONFIG_NOGLOBAL`.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-config.txt |  5 +++
 Documentation/git.txt        | 10 +++++
 config.c                     | 34 ++++++++++++++--
 t/t1300-config.sh            | 75 ++++++++++++++++++++++++++++++++++++
 4 files changed, 120 insertions(+), 4 deletions(-)

Comments

Jeff King April 9, 2021, 3:38 p.m. UTC | #1
On Fri, Apr 09, 2021 at 03:43:30PM +0200, Patrick Steinhardt wrote:

> In order to have git run in a fully controlled environment without any
> misconfiguration, it may be desirable for users or scripts to override
> global- and system-level configuration files. We already have a way of
> doing this, which is to unset both HOME and XDG_CONFIG_HOME environment
> variables and to set `GIT_CONFIG_NOGLOBAL=true`. This is quite kludgy,
> and unsetting the first two variables likely has an impact on other
> executables spawned by such a script.
> 
> The obvious way to fix this would be to introduce `GIT_CONFIG_NOSYSTEM`
> as an equivalent to `GIT_CONFIG_NOGLOBAL`. But in the past, it has

I think you have NOSYSTEM and NOGLOBAL mixed up in both paragraphs here?

Otherwise the motivation and description here look very good (and I like
the overall direction).

> diff --git a/Documentation/git.txt b/Documentation/git.txt
> index 3a9c44987f..2129629296 100644
> --- a/Documentation/git.txt
> +++ b/Documentation/git.txt
> @@ -670,6 +670,16 @@ for further details.
>  	If this environment variable is set to `0`, git will not prompt
>  	on the terminal (e.g., when asking for HTTP authentication).
>  
> +`GIT_CONFIG_GLOBAL`::
> +`GIT_CONFIG_SYSTEM`::
> +	Take the configuration from the given files instead from global or
> +	system-level configuration files. The files must exist and be readable
> +	by the current user. If `GIT_CONFIG_SYSTEM` is set, `/etc/gitconfig`
> +	will not be read. Likewise, if `GIT_CONFIG_GLOBAL` is set, neither
> +	`$HOME/.gitconfig` nor `$XDG_CONFIG_HOME/git/config` will be read. Can
> +	be set to `/dev/null` to skip reading configuration files of the
> +	respective level.

Makes sense. The reference to `/etc/gitconfig` here may not be accurate,
depending on the build parameters. I notice below in the context that we
say:

>  `GIT_CONFIG_NOSYSTEM`::
>  	Whether to skip reading settings from the system-wide
>  	`$(prefix)/etc/gitconfig` file.  This environment variable can

which is _also_ not quite right (if $(prefix) is "/usr", then the file
really is /etc/gitconfig).

I think it might be possible to pull the value of the ETC_GITCONFIG
Makefile variable into the documentation, so we could probably give the
"real" value. But I wonder if it would suffice to just say:

   ...the system config (usually `/etc/gitconfig`) will not be read.

Or is that too confusing (it invites the question "when is it not
/etc/gitconfig")? I guess we could say "the system config file defined
at build-time (usually `/etc/gitconfig`)", which is perhaps more clear.

> @@ -1847,8 +1847,22 @@ static int git_config_from_blob_ref(config_fn_t fn,
>  const char *git_system_config(void)
>  {
>  	static const char *system_wide;
> -	if (!system_wide)
> -		system_wide = system_path(ETC_GITCONFIG);
> +
> +	if (!system_wide) {
> +		system_wide = xstrdup_or_null(getenv("GIT_CONFIG_SYSTEM"));

I wondered, given the "const char *" return values in the last patch, if
you might pass back the result of getenv() directly. But you duplicate
it here, which is good, as it avoids portability problems hanging on to
the result of getenv().

> +		if (system_wide) {
> +			/*
> +			 * If GIT_CONFIG_SYSTEM is set, it overrides the
> +			 * /etc/gitconfig. Furthermore, the file must exist in
> +			 * order to prevent any typos by the user.
> +			 */
> +			if (access(system_wide, R_OK))
> +				die(_("cannot access '%s'"), system_wide);
> +		} else {
> +			system_wide = system_path(ETC_GITCONFIG);
> +		}
> +	}

I was on the fence about whether to enforce the "this file must exist"
property, with respect to the overall design. But seeing how we must
actually add extra code here to handle it makes me want to just treat it
exactly like the other files.

Using a separate access() here is also a TOCTOU race, but I'm pretty
sure the existing config code makes the same mistake (and it's not that
big a deal in this context).

> @@ -1857,8 +1871,20 @@ void git_global_config(const char **user, const char **xdg)
>  	static const char *user_config, *xdg_config;
>  
>  	if (!user_config) {
> -		user_config = expand_user_path("~/.gitconfig", 0);
> -		xdg_config = xdg_config_home("config");
> +		user_config = xstrdup_or_null(getenv("GIT_CONFIG_GLOBAL"));
> +		if (user_config) {
> +			/*
> +			 * If GIT_CONFIG_GLOBAL is set, then it overrides both
> +			 * the ~/.gitconfig and the XDG configuration file.
> +			 * Furthermore, the file must exist in order to prevent
> +			 * any typos by the user.
> +			 */
> +			if (access(user_config, R_OK))
> +				die(_("cannot access '%s'"), user_config);
> +		} else {
> +			user_config = expand_user_path("~/.gitconfig", 0);
> +			xdg_config = xdg_config_home("config");
> +		}
>  	}

And this looks as I'd expect (but the same comments as above apply, of
course).

> +test_expect_success 'override global and system config' '
> +	test_when_finished rm -f "$HOME"/.config/git &&
> +
> +	cat >"$HOME"/.gitconfig <<-EOF &&
> +	[home]
> +		config = true
> +	EOF
> +	mkdir -p "$HOME"/.config/git &&
> +	cat >"$HOME"/.config/git/config <<-EOF &&
> +	[xdg]
> +		config = true
> +	EOF
> +	cat >.git/config <<-EOF &&
> +	[local]
> +		config = true
> +	EOF
> +	cat >custom-global-config <<-EOF &&
> +	[global]
> +		config = true
> +	EOF
> +	cat >custom-system-config <<-EOF &&
> +	[system]
> +		config = true
> +	EOF

Minor style nit, but we usually prefer non-interpolating "\EOF" if we
don't intend to interpolate within the here-doc. It does look like
t1300 has quite a mix of styles, though.

> +	cat >expect <<-EOF &&
> +	global	xdg.config=true
> +	global	home.config=true
> +	local	local.config=true
> +	EOF
> +	git config --show-scope --list >output &&
> +	test_cmp expect output &&
> +
> +	sane_unset GIT_CONFIG_NOSYSTEM &&
> +
> +	cat >expect <<-EOF &&
> +	system	system.config=true
> +	global	global.config=true
> +	local	local.config=true
> +	EOF
> +	GIT_CONFIG_SYSTEM=custom-system-config GIT_CONFIG_GLOBAL=custom-global-config \
> +		git config --show-scope --list >output &&
> +	test_cmp expect output &&
> +
> +	cat >expect <<-EOF &&
> +	local	local.config=true
> +	EOF
> +	GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_GLOBAL=/dev/null git config --show-scope --list >output &&
> +	test_cmp expect output
> +'

And this test covers all of the new stuff we care about. Good.

> +test_expect_success 'override global and system config with missing file' '
> +	sane_unset GIT_CONFIG_NOSYSTEM &&
> +	test_must_fail env GIT_CONFIG_GLOBAL=does-not-exist git version &&
> +	test_must_fail env GIT_CONFIG_SYSTEM=does-not-exist git version &&
> +	GIT_CONFIG_NOSYSTEM=true GIT_CONFIG_SYSTEM=does-not-exist git version
> +'

Makes sense to test given the patch, though if we rip out the "missing"
check, then obviously this goes away.

> +test_expect_success 'write to overridden global and system config' '

Hmm. I hadn't really considered _writing_ to these files (after all, you
can just use "git config --file" to do so). I guess it is consistent,
and would probably be more work (and more error-prone) to try to
distinguish reading versus writing in the code.

> +	cat >expect <<EOF &&
> +[config]
> +	key = value
> +EOF

No "<<-EOF" here to fix the indentation?

> +	test_must_fail env GIT_CONFIG_GLOBAL=write-to-global git config --global config.key value &&
> +	touch write-to-global &&
> +	GIT_CONFIG_GLOBAL=write-to-global git config --global config.key value &&
> +	test_cmp expect write-to-global &&

In the writing case, the "must exist" thing makes it even weirder, since
we might be intending to create the file! If we leave in the writing,
that makes me even more convinced that we should drop the "must exist"
check.

-Peff
Junio C Hamano April 9, 2021, 10:18 p.m. UTC | #2
Patrick Steinhardt <ps@pks.im> writes:

> diff --git a/config.c b/config.c
> index 6af0244085..9dfc4a79f7 100644
> --- a/config.c
> +++ b/config.c
> @@ -1847,8 +1847,22 @@ static int git_config_from_blob_ref(config_fn_t fn,
>  const char *git_system_config(void)
>  {
>  	static const char *system_wide;
> -	if (!system_wide)
> -		system_wide = system_path(ETC_GITCONFIG);
> +
> +	if (!system_wide) {
> +		system_wide = xstrdup_or_null(getenv("GIT_CONFIG_SYSTEM"));
> +		if (system_wide) {
> +			/*
> +			 * If GIT_CONFIG_SYSTEM is set, it overrides the
> +			 * /etc/gitconfig. Furthermore, the file must exist in
> +			 * order to prevent any typos by the user.
> +			 */
> +			if (access(system_wide, R_OK))
> +				die(_("cannot access '%s'"), system_wide);
> +		} else {
> +			system_wide = system_path(ETC_GITCONFIG);
> +		}
> +	}
> +
>  	return system_wide;
>  }

I wrote the design to truly special case "/dev/null" at the pathname
string level, and we do not even try to open/read from a file when
"/dev/null" is given, so that we can even allow "/dev/null" to be
missing in a funny embedded or containerized environment, but that
does not seem to be what is going on here.

I do not think "access()" here is a good idea; the result we get
here may not match what happens when we actually try to open the
path.  Just remembering if we got the system_wide value from the
GIT_CONFIG_SYSTEM, and change what happens when the open fails when
we try to use the system-wide configuration depending on that, would
be the right approach.
Patrick Steinhardt April 12, 2021, 2:04 p.m. UTC | #3
On Fri, Apr 09, 2021 at 11:38:31AM -0400, Jeff King wrote:
> On Fri, Apr 09, 2021 at 03:43:30PM +0200, Patrick Steinhardt wrote:
> 
> > In order to have git run in a fully controlled environment without any
> > misconfiguration, it may be desirable for users or scripts to override
> > global- and system-level configuration files. We already have a way of
> > doing this, which is to unset both HOME and XDG_CONFIG_HOME environment
> > variables and to set `GIT_CONFIG_NOGLOBAL=true`. This is quite kludgy,
> > and unsetting the first two variables likely has an impact on other
> > executables spawned by such a script.
> > 
> > The obvious way to fix this would be to introduce `GIT_CONFIG_NOSYSTEM`
> > as an equivalent to `GIT_CONFIG_NOGLOBAL`. But in the past, it has
> 
> I think you have NOSYSTEM and NOGLOBAL mixed up in both paragraphs here?
> 
> Otherwise the motivation and description here look very good (and I like
> the overall direction).
> 
> > diff --git a/Documentation/git.txt b/Documentation/git.txt
> > index 3a9c44987f..2129629296 100644
> > --- a/Documentation/git.txt
> > +++ b/Documentation/git.txt
> > @@ -670,6 +670,16 @@ for further details.
> >  	If this environment variable is set to `0`, git will not prompt
> >  	on the terminal (e.g., when asking for HTTP authentication).
> >  
> > +`GIT_CONFIG_GLOBAL`::
> > +`GIT_CONFIG_SYSTEM`::
> > +	Take the configuration from the given files instead from global or
> > +	system-level configuration files. The files must exist and be readable
> > +	by the current user. If `GIT_CONFIG_SYSTEM` is set, `/etc/gitconfig`
> > +	will not be read. Likewise, if `GIT_CONFIG_GLOBAL` is set, neither
> > +	`$HOME/.gitconfig` nor `$XDG_CONFIG_HOME/git/config` will be read. Can
> > +	be set to `/dev/null` to skip reading configuration files of the
> > +	respective level.
> 
> Makes sense. The reference to `/etc/gitconfig` here may not be accurate,
> depending on the build parameters. I notice below in the context that we
> say:
> 
> >  `GIT_CONFIG_NOSYSTEM`::
> >  	Whether to skip reading settings from the system-wide
> >  	`$(prefix)/etc/gitconfig` file.  This environment variable can
> 
> which is _also_ not quite right (if $(prefix) is "/usr", then the file
> really is /etc/gitconfig).
> 
> I think it might be possible to pull the value of the ETC_GITCONFIG
> Makefile variable into the documentation, so we could probably give the
> "real" value. But I wonder if it would suffice to just say:
> 
>    ...the system config (usually `/etc/gitconfig`) will not be read.
> 
> Or is that too confusing (it invites the question "when is it not
> /etc/gitconfig")? I guess we could say "the system config file defined
> at build-time (usually `/etc/gitconfig`)", which is perhaps more clear.
> 
> > @@ -1847,8 +1847,22 @@ static int git_config_from_blob_ref(config_fn_t fn,
> >  const char *git_system_config(void)
> >  {
> >  	static const char *system_wide;
> > -	if (!system_wide)
> > -		system_wide = system_path(ETC_GITCONFIG);
> > +
> > +	if (!system_wide) {
> > +		system_wide = xstrdup_or_null(getenv("GIT_CONFIG_SYSTEM"));
> 
> I wondered, given the "const char *" return values in the last patch, if
> you might pass back the result of getenv() directly. But you duplicate
> it here, which is good, as it avoids portability problems hanging on to
> the result of getenv().
> 
> > +		if (system_wide) {
> > +			/*
> > +			 * If GIT_CONFIG_SYSTEM is set, it overrides the
> > +			 * /etc/gitconfig. Furthermore, the file must exist in
> > +			 * order to prevent any typos by the user.
> > +			 */
> > +			if (access(system_wide, R_OK))
> > +				die(_("cannot access '%s'"), system_wide);
> > +		} else {
> > +			system_wide = system_path(ETC_GITCONFIG);
> > +		}
> > +	}
> 
> I was on the fence about whether to enforce the "this file must exist"
> property, with respect to the overall design. But seeing how we must
> actually add extra code here to handle it makes me want to just treat it
> exactly like the other files.
> 
> Using a separate access() here is also a TOCTOU race, but I'm pretty
> sure the existing config code makes the same mistake (and it's not that
> big a deal in this context).
> 
> > @@ -1857,8 +1871,20 @@ void git_global_config(const char **user, const char **xdg)
> >  	static const char *user_config, *xdg_config;
> >  
> >  	if (!user_config) {
> > -		user_config = expand_user_path("~/.gitconfig", 0);
> > -		xdg_config = xdg_config_home("config");
> > +		user_config = xstrdup_or_null(getenv("GIT_CONFIG_GLOBAL"));
> > +		if (user_config) {
> > +			/*
> > +			 * If GIT_CONFIG_GLOBAL is set, then it overrides both
> > +			 * the ~/.gitconfig and the XDG configuration file.
> > +			 * Furthermore, the file must exist in order to prevent
> > +			 * any typos by the user.
> > +			 */
> > +			if (access(user_config, R_OK))
> > +				die(_("cannot access '%s'"), user_config);
> > +		} else {
> > +			user_config = expand_user_path("~/.gitconfig", 0);
> > +			xdg_config = xdg_config_home("config");
> > +		}
> >  	}
> 
> And this looks as I'd expect (but the same comments as above apply, of
> course).
> 
> > +test_expect_success 'override global and system config' '
> > +	test_when_finished rm -f "$HOME"/.config/git &&
> > +
> > +	cat >"$HOME"/.gitconfig <<-EOF &&
> > +	[home]
> > +		config = true
> > +	EOF
> > +	mkdir -p "$HOME"/.config/git &&
> > +	cat >"$HOME"/.config/git/config <<-EOF &&
> > +	[xdg]
> > +		config = true
> > +	EOF
> > +	cat >.git/config <<-EOF &&
> > +	[local]
> > +		config = true
> > +	EOF
> > +	cat >custom-global-config <<-EOF &&
> > +	[global]
> > +		config = true
> > +	EOF
> > +	cat >custom-system-config <<-EOF &&
> > +	[system]
> > +		config = true
> > +	EOF
> 
> Minor style nit, but we usually prefer non-interpolating "\EOF" if we
> don't intend to interpolate within the here-doc. It does look like
> t1300 has quite a mix of styles, though.
> 
> > +	cat >expect <<-EOF &&
> > +	global	xdg.config=true
> > +	global	home.config=true
> > +	local	local.config=true
> > +	EOF
> > +	git config --show-scope --list >output &&
> > +	test_cmp expect output &&
> > +
> > +	sane_unset GIT_CONFIG_NOSYSTEM &&
> > +
> > +	cat >expect <<-EOF &&
> > +	system	system.config=true
> > +	global	global.config=true
> > +	local	local.config=true
> > +	EOF
> > +	GIT_CONFIG_SYSTEM=custom-system-config GIT_CONFIG_GLOBAL=custom-global-config \
> > +		git config --show-scope --list >output &&
> > +	test_cmp expect output &&
> > +
> > +	cat >expect <<-EOF &&
> > +	local	local.config=true
> > +	EOF
> > +	GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_GLOBAL=/dev/null git config --show-scope --list >output &&
> > +	test_cmp expect output
> > +'
> 
> And this test covers all of the new stuff we care about. Good.
> 
> > +test_expect_success 'override global and system config with missing file' '
> > +	sane_unset GIT_CONFIG_NOSYSTEM &&
> > +	test_must_fail env GIT_CONFIG_GLOBAL=does-not-exist git version &&
> > +	test_must_fail env GIT_CONFIG_SYSTEM=does-not-exist git version &&
> > +	GIT_CONFIG_NOSYSTEM=true GIT_CONFIG_SYSTEM=does-not-exist git version
> > +'
> 
> Makes sense to test given the patch, though if we rip out the "missing"
> check, then obviously this goes away.
> 
> > +test_expect_success 'write to overridden global and system config' '
> 
> Hmm. I hadn't really considered _writing_ to these files (after all, you
> can just use "git config --file" to do so). I guess it is consistent,
> and would probably be more work (and more error-prone) to try to
> distinguish reading versus writing in the code.
> 
> > +	cat >expect <<EOF &&
> > +[config]
> > +	key = value
> > +EOF
> 
> No "<<-EOF" here to fix the indentation?
> 
> > +	test_must_fail env GIT_CONFIG_GLOBAL=write-to-global git config --global config.key value &&
> > +	touch write-to-global &&
> > +	GIT_CONFIG_GLOBAL=write-to-global git config --global config.key value &&
> > +	test_cmp expect write-to-global &&
> 
> In the writing case, the "must exist" thing makes it even weirder, since
> we might be intending to create the file! If we leave in the writing,
> that makes me even more convinced that we should drop the "must exist"
> check.
> 
> -Peff

I do agree that for the writing side it's limiting to require the file
to exist. The question is really what's gained by it. The worst thing
that could happen is that the user writes to a file he didn't intend to
write to -- this can happen regardless of whether or not the file
exists, and in fact the worse case is where we overwrite values of a
file which the user didn't intend to overwrite. The case where we create
a new file by accident doesn't seem to be that interesting to me.

In any case, the user would probably use `git config -f` anyway. Which
raises the question whether it's sensible to allow writing to the file
in the first place. And for the sake of scripts, I lean towards "yes":
the dev can set up envvars once, and then use the config for both
reading and writing.

On the reading side, I can relate with Junio's argument that there's now
two possibilities for typos: once in the envvar, and once in the file
path. But given that git won't check the envvar's key for typos, we
cannot really reduce the chance for typos down to zero anyway.

So I do tend towards just allowing for the file to not exist: when
reading, we silentely ignore it, and when writing we create it.

Patrick
diff mbox series

Patch

diff --git a/Documentation/git-config.txt b/Documentation/git-config.txt
index 4b4cc5c5e8..5cddadafd2 100644
--- a/Documentation/git-config.txt
+++ b/Documentation/git-config.txt
@@ -340,6 +340,11 @@  GIT_CONFIG::
 	Using the "--global" option forces this to ~/.gitconfig. Using the
 	"--system" option forces this to $(prefix)/etc/gitconfig.
 
+GIT_CONFIG_GLOBAL::
+GIT_CONFIG_SYSTEM::
+	Take the configuration from the given files instead from global or
+	system-level configuration. See linkgit:git[1] for details.
+
 GIT_CONFIG_NOSYSTEM::
 	Whether to skip reading settings from the system-wide
 	$(prefix)/etc/gitconfig file. See linkgit:git[1] for details.
diff --git a/Documentation/git.txt b/Documentation/git.txt
index 3a9c44987f..2129629296 100644
--- a/Documentation/git.txt
+++ b/Documentation/git.txt
@@ -670,6 +670,16 @@  for further details.
 	If this environment variable is set to `0`, git will not prompt
 	on the terminal (e.g., when asking for HTTP authentication).
 
+`GIT_CONFIG_GLOBAL`::
+`GIT_CONFIG_SYSTEM`::
+	Take the configuration from the given files instead from global or
+	system-level configuration files. The files must exist and be readable
+	by the current user. If `GIT_CONFIG_SYSTEM` is set, `/etc/gitconfig`
+	will not be read. Likewise, if `GIT_CONFIG_GLOBAL` is set, neither
+	`$HOME/.gitconfig` nor `$XDG_CONFIG_HOME/git/config` will be read. Can
+	be set to `/dev/null` to skip reading configuration files of the
+	respective level.
+
 `GIT_CONFIG_NOSYSTEM`::
 	Whether to skip reading settings from the system-wide
 	`$(prefix)/etc/gitconfig` file.  This environment variable can
diff --git a/config.c b/config.c
index 6af0244085..9dfc4a79f7 100644
--- a/config.c
+++ b/config.c
@@ -1847,8 +1847,22 @@  static int git_config_from_blob_ref(config_fn_t fn,
 const char *git_system_config(void)
 {
 	static const char *system_wide;
-	if (!system_wide)
-		system_wide = system_path(ETC_GITCONFIG);
+
+	if (!system_wide) {
+		system_wide = xstrdup_or_null(getenv("GIT_CONFIG_SYSTEM"));
+		if (system_wide) {
+			/*
+			 * If GIT_CONFIG_SYSTEM is set, it overrides the
+			 * /etc/gitconfig. Furthermore, the file must exist in
+			 * order to prevent any typos by the user.
+			 */
+			if (access(system_wide, R_OK))
+				die(_("cannot access '%s'"), system_wide);
+		} else {
+			system_wide = system_path(ETC_GITCONFIG);
+		}
+	}
+
 	return system_wide;
 }
 
@@ -1857,8 +1871,20 @@  void git_global_config(const char **user, const char **xdg)
 	static const char *user_config, *xdg_config;
 
 	if (!user_config) {
-		user_config = expand_user_path("~/.gitconfig", 0);
-		xdg_config = xdg_config_home("config");
+		user_config = xstrdup_or_null(getenv("GIT_CONFIG_GLOBAL"));
+		if (user_config) {
+			/*
+			 * If GIT_CONFIG_GLOBAL is set, then it overrides both
+			 * the ~/.gitconfig and the XDG configuration file.
+			 * Furthermore, the file must exist in order to prevent
+			 * any typos by the user.
+			 */
+			if (access(user_config, R_OK))
+				die(_("cannot access '%s'"), user_config);
+		} else {
+			user_config = expand_user_path("~/.gitconfig", 0);
+			xdg_config = xdg_config_home("config");
+		}
 	}
 
 	*user = user_config;
diff --git a/t/t1300-config.sh b/t/t1300-config.sh
index e0dd5d65ce..5498ca32b0 100755
--- a/t/t1300-config.sh
+++ b/t/t1300-config.sh
@@ -2059,6 +2059,81 @@  test_expect_success '--show-scope with --show-origin' '
 	test_cmp expect output
 '
 
+test_expect_success 'override global and system config' '
+	test_when_finished rm -f "$HOME"/.config/git &&
+
+	cat >"$HOME"/.gitconfig <<-EOF &&
+	[home]
+		config = true
+	EOF
+	mkdir -p "$HOME"/.config/git &&
+	cat >"$HOME"/.config/git/config <<-EOF &&
+	[xdg]
+		config = true
+	EOF
+	cat >.git/config <<-EOF &&
+	[local]
+		config = true
+	EOF
+	cat >custom-global-config <<-EOF &&
+	[global]
+		config = true
+	EOF
+	cat >custom-system-config <<-EOF &&
+	[system]
+		config = true
+	EOF
+
+	cat >expect <<-EOF &&
+	global	xdg.config=true
+	global	home.config=true
+	local	local.config=true
+	EOF
+	git config --show-scope --list >output &&
+	test_cmp expect output &&
+
+	sane_unset GIT_CONFIG_NOSYSTEM &&
+
+	cat >expect <<-EOF &&
+	system	system.config=true
+	global	global.config=true
+	local	local.config=true
+	EOF
+	GIT_CONFIG_SYSTEM=custom-system-config GIT_CONFIG_GLOBAL=custom-global-config \
+		git config --show-scope --list >output &&
+	test_cmp expect output &&
+
+	cat >expect <<-EOF &&
+	local	local.config=true
+	EOF
+	GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_GLOBAL=/dev/null git config --show-scope --list >output &&
+	test_cmp expect output
+'
+
+test_expect_success 'override global and system config with missing file' '
+	sane_unset GIT_CONFIG_NOSYSTEM &&
+	test_must_fail env GIT_CONFIG_GLOBAL=does-not-exist git version &&
+	test_must_fail env GIT_CONFIG_SYSTEM=does-not-exist git version &&
+	GIT_CONFIG_NOSYSTEM=true GIT_CONFIG_SYSTEM=does-not-exist git version
+'
+
+test_expect_success 'write to overridden global and system config' '
+	cat >expect <<EOF &&
+[config]
+	key = value
+EOF
+
+	test_must_fail env GIT_CONFIG_GLOBAL=write-to-global git config --global config.key value &&
+	touch write-to-global &&
+	GIT_CONFIG_GLOBAL=write-to-global git config --global config.key value &&
+	test_cmp expect write-to-global &&
+
+	test_must_fail env GIT_CONFIG_SYSTEM=write-to-system git config --system config.key value &&
+	touch write-to-system &&
+	GIT_CONFIG_SYSTEM=write-to-system git config --system config.key value &&
+	test_cmp expect write-to-system
+'
+
 for opt in --local --worktree
 do
 	test_expect_success "$opt requires a repo" '