From 89fd9aca2fe5ea457e8c603a3d6e1917456388bb Mon Sep 17 00:00:00 2001 From: Delilah Ashley Wu Date: Sat, 13 Jun 2026 13:13:12 +1000 Subject: [PATCH 1/3] path: use forward slashes in XDG config on Windows Git prefers forward slashes as directory separators across all platforms. On Windows, the backslash is the native directory separator, but all Windows versions supported by Git also accept the forward slash in all but rare circumstances. Our tests expect forward slashes. Git displays relative paths with forward slashes. Forward slashes are more convenient to use in shell scripts. For these reasons, we enforced forward slashes in `interpolate_path()` in 5ca6b7bb47b (config --show-origin: report paths with forward slashes, 2016-03-23). However, other code paths may construct paths containing backslashes. For example, `config --show-origin` prints the XDG config path with mixed slashes on Windows: $ git config --list --show-origin file:C:/Program Files/Git/etc/gitconfig system.foo=bar file:"C:\\Users\\delilah/.config/git/config" xdg.foo=bar file:C:/Users/delilah/.gitconfig home.foo=bar file:.git/config local.foo=bar On Windows, these inconsistent slashes occur because the `$HOME` and `$XDG_CONFIG_HOME` environment variables may contain backslashes when `xdg_config_home_for()` interpolates them into one of two templates: `$HOME/.config//` or `$XDG_CONFIG_HOME//`. Since callers of `xdg_config_home_for()` handle mixed slashes correctly, it is reasonable to assume that they can handle paths with only forward slashes. Let's enforce forward slashes in `xdg_config_home_for()` by calling `convert_slashes()` on the interpolated path on Windows. Avoid modifying the strings returned from `getenv()`, which would lead to undefined behaviour. Signed-off-by: Delilah Ashley Wu --- path.c | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/path.c b/path.c index c3a709a9284b7f..f17595fd1bbb2b 100644 --- a/path.c +++ b/path.c @@ -1544,19 +1544,23 @@ int looks_like_command_line_option(const char *str) char *xdg_config_home_for(const char *subdir, const char *filename) { + char *ret; const char *home, *config_home; assert(subdir); assert(filename); config_home = getenv("XDG_CONFIG_HOME"); if (config_home && *config_home) - return mkpathdup("%s/%s/%s", config_home, subdir, filename); - - home = getenv("HOME"); - if (home) - return mkpathdup("%s/.config/%s/%s", home, subdir, filename); + ret = mkpathdup("%s/%s/%s", config_home, subdir, filename); + else if ((home = getenv("HOME"))) + ret = mkpathdup("%s/.config/%s/%s", home, subdir, filename); + else + return NULL; - return NULL; +#ifdef GIT_WINDOWS_NATIVE + convert_slashes(ret); +#endif + return ret; } char *xdg_config_home(const char *filename) From 25af55b1bca733440be7b0ccda605b9d56e8aaf5 Mon Sep 17 00:00:00 2001 From: Delilah Ashley Wu Date: Tue, 23 Dec 2025 18:06:51 +1100 Subject: [PATCH 2/3] config: fail if no config_sequence file succeeds Teach `do_git_config_sequence()` to optionally report an error if none of the configuration files in the sequence were successfully processed. Introduce a flag to enable this new behaviour and leave it disabled by default to avoid breaking existing callers. The next patch, config: read global scope via config_sequence, changes how `git config list --global` reads the global configuration. It uses this new flag to prevent a regression by ensuring the command continues to fail when both global config files are nonexistent. Signed-off-by: Delilah Ashley Wu --- config.c | 57 +++++++++++++++++++++++++++++++++-------------- t/t1300-config.sh | 12 ++++++++++ 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/config.c b/config.c index 1bdd702e7a3969..cf74888dd1af4e 100644 --- a/config.c +++ b/config.c @@ -1544,11 +1544,26 @@ int git_config_system(void) return !git_env_bool("GIT_CONFIG_NOSYSTEM", 0); } +static void attempt_git_config_from_file_with_options(config_fn_t fn, + const char *filename, + void *data, + enum config_scope scope, + const struct config_options *opts, + int *success_count, + int *cumulative_ret) +{ + int ret = git_config_from_file_with_options(fn, filename, data, scope, opts); + if (!ret) + (*success_count)++; + *cumulative_ret += ret; +} + static int do_git_config_sequence(const struct config_options *opts, - const struct repository *repo, - config_fn_t fn, void *data) + const struct repository *repo, config_fn_t fn, + void *data, int require_successful_config) { int ret = 0; + int success_count = 0; char *system_config = git_system_config(); char *xdg_config = NULL; char *user_config = NULL; @@ -1574,32 +1589,35 @@ static int do_git_config_sequence(const struct config_options *opts, if (git_config_system() && system_config && !access_or_die(system_config, R_OK, opts->system_gently ? ACCESS_EACCES_OK : 0)) - ret += git_config_from_file_with_options(fn, system_config, - data, CONFIG_SCOPE_SYSTEM, - NULL); + attempt_git_config_from_file_with_options(fn, system_config, data, + CONFIG_SCOPE_SYSTEM, NULL, + &success_count, &ret); git_global_config_paths(&user_config, &xdg_config); if (xdg_config && !access_or_die(xdg_config, R_OK, ACCESS_EACCES_OK)) - ret += git_config_from_file_with_options(fn, xdg_config, data, - CONFIG_SCOPE_GLOBAL, NULL); + attempt_git_config_from_file_with_options(fn, xdg_config, + data, + CONFIG_SCOPE_GLOBAL, + NULL, &success_count, &ret); if (user_config && !access_or_die(user_config, R_OK, ACCESS_EACCES_OK)) - ret += git_config_from_file_with_options(fn, user_config, data, - CONFIG_SCOPE_GLOBAL, NULL); + attempt_git_config_from_file_with_options(fn, user_config, + data, + CONFIG_SCOPE_GLOBAL, + NULL, &success_count, &ret); if (!opts->ignore_repo && repo_config && !access_or_die(repo_config, R_OK, 0)) - ret += git_config_from_file_with_options(fn, repo_config, data, - CONFIG_SCOPE_LOCAL, NULL); + attempt_git_config_from_file_with_options(fn, repo_config, data, + CONFIG_SCOPE_LOCAL, NULL, &success_count, &ret); if (!opts->ignore_worktree && worktree_config && repo && repo->repository_format_worktree_config && - !access_or_die(worktree_config, R_OK, 0)) { - ret += git_config_from_file_with_options(fn, worktree_config, data, - CONFIG_SCOPE_WORKTREE, - NULL); - } + !access_or_die(worktree_config, R_OK, 0)) + attempt_git_config_from_file_with_options(fn, worktree_config, data, + CONFIG_SCOPE_WORKTREE, + NULL, &success_count, &ret); if (!opts->ignore_cmdline && git_config_from_parameters(fn, data) < 0) die(_("unable to parse command-line config")); @@ -1609,6 +1627,10 @@ static int do_git_config_sequence(const struct config_options *opts, free(user_config); free(repo_config); free(worktree_config); + + if (require_successful_config && !success_count && !ret) + ret = -1; + return ret; } @@ -1644,7 +1666,8 @@ int config_with_options(config_fn_t fn, void *data, ret = git_config_from_blob_ref(fn, repo, config_source->blob, data, config_source->scope); } else { - ret = do_git_config_sequence(opts, repo, fn, data); + ret = do_git_config_sequence(opts, repo, fn, data, + config_source && config_source->scope == CONFIG_SCOPE_GLOBAL); } if (inc.remote_urls) { diff --git a/t/t1300-config.sh b/t/t1300-config.sh index e3f8064889210a..df8c14da0d8d00 100755 --- a/t/t1300-config.sh +++ b/t/t1300-config.sh @@ -2425,6 +2425,18 @@ test_expect_success '--show-scope with --default' ' test_cmp expect actual ' +test_expect_success 'list with nonexistent global config gracefully exits' ' + rm -f "$HOME"/.gitconfig "$HOME"/.config/git/config && + git config ${mode_prefix}list && + git config ${mode_prefix}list --show-scope +' + +test_expect_success 'list --global with nonexistent global config fails' ' + rm -f "$HOME"/.gitconfig "$HOME"/.config/git/config && + test_must_fail git config ${mode_prefix}list --global && + test_must_fail git config ${mode_prefix}list --global --show-scope +' + test_expect_success 'override global and system config' ' test_when_finished rm -f \"\$HOME\"/.gitconfig && cat >"$HOME"/.gitconfig <<-EOF && From 33d79b9bd4c4a409cb62a04f951726968973f7dc Mon Sep 17 00:00:00 2001 From: Delilah Ashley Wu Date: Thu, 22 May 2025 16:32:15 +1000 Subject: [PATCH 3/3] config: read global scope via config_sequence When both `$HOME/.gitconfig` and `$XDG_CONFIG_HOME/git/config` exist, `git config list --global` and `git config --get --global` read the home configuration file but ignore the XDG file. Bug reporters expected these commands to read both files and be consistent with the documentation and the behaviour of the unscoped `git config list` and `git config --get` commands [1][2], which read from both files (in addition to system-wide and repository-specific entries, of course). We assume each scope corresponds to a single configuration file. So, during `--global` operations, Git selects a single path and passes it to `git_config_from_file_with_options(path)`. Since global configuration comes from two files, we should read the configuration files with another method. Running `git config list --show-scope --show-origin` (without `--global`) reads both the home and XDG files. So there's existing code that respects both locations, namely `do_git_config_sequence()` which reads from all scopes. Introduce flags to ignore all but the global scope (i.e. ignore system, local, worktree, and cmdline). Then, reuse the function to read only the global scope when `--global` is specified. This was the suggested solution [3] in the original bug report. Modify tests to check that both configuration files are respected during `--global` read operations and add tests to ensure we do not introduce regressions. Specifically: - The home config should take precedence over the XDG config. - `git config list` should not fail on non-existent global config. - `git config list --global` should fail when both global config files are non-existent. It should not fail if at least one of them exists. Implementation notes: - The `ignore_global` flag is not set anywhere, so the `if (!opts->ignore_global)` condition is always met. Include the flag for completeness. - Keep populating `opts->source.file` in `builtin/config.c` because it is used as the destination config file for write operations. The proposed changes could convolute the code because there is no single source of truth for the config file locations in the global scope. Add a comment to clarify this. [1] https://lore.kernel.org/git/CAFA9we-QLQRzJdGMMCPatmfrk1oHeiUu9msMRXXk1MLE5HRxBQ@mail.gmail.com/ [2] https://lore.kernel.org/git/CAAdFe9yhBk-WecVzCTsjQ-4Z3AZAbpP+w+B076ouM3qX6d1WAg@mail.gmail.com/ [3] https://lore.kernel.org/git/kl6ly1oze7wb.fsf@chooglen-macbookpro.roam.corp.google.com Reported-by: Jade Lovelace Reported-by: Nils Fahldieck Suggested-by: Glen Choo Helped-by: Derrick Stolee Helped-by: Johannes Schindelin Signed-off-by: Delilah Ashley Wu --- builtin/config.c | 11 +++++++ config.c | 34 +++++++++++---------- config.h | 2 ++ t/t1300-config.sh | 71 ++++++++++++++++++++++++++++++++++++++++++++ t/t1306-xdg-files.sh | 5 +++- 5 files changed, 107 insertions(+), 16 deletions(-) diff --git a/builtin/config.c b/builtin/config.c index 0882899c3fbd2a..a7468e86d3e5b7 100644 --- a/builtin/config.c +++ b/builtin/config.c @@ -957,6 +957,17 @@ static void location_options_init(struct config_location_options *opts, } if (opts->use_global_config) { + /* + * Since global config is sourced from more than one location, + * read it using `do_git_config_sequence()` with other scopes + * ignored. However, writing global config should point to a + * single destination, set in `opts->source.file`. + */ + opts->options.ignore_repo = 1; + opts->options.ignore_cmdline = 1; + opts->options.ignore_worktree = 1; + opts->options.ignore_system = 1; + opts->source.file = opts->file_to_free = git_global_config(); if (!opts->source.file) /* diff --git a/config.c b/config.c index cf74888dd1af4e..65be08c7b8eb9b 100644 --- a/config.c +++ b/config.c @@ -1586,26 +1586,31 @@ static int do_git_config_sequence(const struct config_options *opts, worktree_config = NULL; } - if (git_config_system() && system_config && + if (!opts->ignore_system && git_config_system() && system_config && !access_or_die(system_config, R_OK, opts->system_gently ? ACCESS_EACCES_OK : 0)) attempt_git_config_from_file_with_options(fn, system_config, data, CONFIG_SCOPE_SYSTEM, NULL, &success_count, &ret); - git_global_config_paths(&user_config, &xdg_config); + if (!opts->ignore_global) { + git_global_config_paths(&user_config, &xdg_config); - if (xdg_config && !access_or_die(xdg_config, R_OK, ACCESS_EACCES_OK)) - attempt_git_config_from_file_with_options(fn, xdg_config, - data, - CONFIG_SCOPE_GLOBAL, - NULL, &success_count, &ret); + if (xdg_config && !access_or_die(xdg_config, R_OK, ACCESS_EACCES_OK)) + attempt_git_config_from_file_with_options(fn, xdg_config, + data, + CONFIG_SCOPE_GLOBAL, + NULL, &success_count, &ret); - if (user_config && !access_or_die(user_config, R_OK, ACCESS_EACCES_OK)) - attempt_git_config_from_file_with_options(fn, user_config, - data, - CONFIG_SCOPE_GLOBAL, - NULL, &success_count, &ret); + if (user_config && !access_or_die(user_config, R_OK, ACCESS_EACCES_OK)) + attempt_git_config_from_file_with_options(fn, user_config, + data, + CONFIG_SCOPE_GLOBAL, + NULL, &success_count, &ret); + + free(xdg_config); + free(user_config); + } if (!opts->ignore_repo && repo_config && !access_or_die(repo_config, R_OK, 0)) @@ -1623,8 +1628,6 @@ static int do_git_config_sequence(const struct config_options *opts, die(_("unable to parse command-line config")); free(system_config); - free(xdg_config); - free(user_config); free(repo_config); free(worktree_config); @@ -1658,7 +1661,8 @@ int config_with_options(config_fn_t fn, void *data, */ if (config_source && config_source->use_stdin) { ret = git_config_from_stdin(fn, data, config_source->scope); - } else if (config_source && config_source->file) { + } else if (config_source && config_source->file && + config_source->scope != CONFIG_SCOPE_GLOBAL) { ret = git_config_from_file_with_options(fn, config_source->file, data, config_source->scope, NULL); diff --git a/config.h b/config.h index 31fe3e29611e11..eb2d7a2843c499 100644 --- a/config.h +++ b/config.h @@ -87,6 +87,8 @@ typedef int (*config_parser_event_fn_t)(enum config_event_t type, struct config_options { unsigned int respect_includes : 1; + unsigned int ignore_system : 1; + unsigned int ignore_global : 1; unsigned int ignore_repo : 1; unsigned int ignore_worktree : 1; unsigned int ignore_cmdline : 1; diff --git a/t/t1300-config.sh b/t/t1300-config.sh index df8c14da0d8d00..b51ddd4ce44eff 100755 --- a/t/t1300-config.sh +++ b/t/t1300-config.sh @@ -2437,6 +2437,77 @@ test_expect_success 'list --global with nonexistent global config fails' ' test_must_fail git config ${mode_prefix}list --global --show-scope ' +test_expect_success 'list and get --global with only home' ' + rm -f "$HOME"/.config/git/config && + + test_when_finished rm -f \"\$HOME\"/.gitconfig && + cat >"$HOME"/.gitconfig <<-EOF && + [home] + config = true + EOF + + cat >expect <<-EOF && + global home.config=true + EOF + git config ${mode_prefix}list --global --show-scope >actual && + test_cmp expect actual && + + echo true >expect && + git config ${mode_get} --global home.config >actual && + test_cmp expect actual +' + +test_expect_success 'list and get --global with only xdg' ' + rm -f "$HOME"/.gitconfig && + + test_when_finished rm -rf \"\$HOME\"/.config/git && + mkdir -p "$HOME"/.config/git && + cat >"$HOME"/.config/git/config <<-EOF && + [xdg] + config = true + EOF + + cat >expect <<-EOF && + global xdg.config=true + EOF + git config ${mode_prefix}list --global --show-scope >actual && + test_cmp expect actual && + + echo true >expect && + git config ${mode_get} --global xdg.config >actual && + test_cmp expect actual +' + +test_expect_success 'list and get --global with both home and xdg' ' + test_when_finished rm -f \"\$HOME\"/.gitconfig && + cat >"$HOME"/.gitconfig <<-EOF && + [home] + config = home + EOF + + test_when_finished rm -rf \"\$HOME\"/.config/git && + mkdir -p "$HOME"/.config/git && + cat >"$HOME"/.config/git/config <<-EOF && + [xdg] + config = xdg + EOF + + cat >expect <<-EOF && + global file:$HOME/.config/git/config xdg.config=xdg + global file:$HOME/.gitconfig home.config=home + EOF + git config ${mode_prefix}list --global --show-scope --show-origin >actual && + test_cmp expect actual && + + echo xdg >expect && + git config ${mode_get} --global xdg.config >actual && + test_cmp expect actual && + + echo home >expect && + git config ${mode_get} --global home.config >actual && + test_cmp expect actual +' + test_expect_success 'override global and system config' ' test_when_finished rm -f \"\$HOME\"/.gitconfig && cat >"$HOME"/.gitconfig <<-EOF && diff --git a/t/t1306-xdg-files.sh b/t/t1306-xdg-files.sh index 40d3c42618c04f..3a9a04bcc143fd 100755 --- a/t/t1306-xdg-files.sh +++ b/t/t1306-xdg-files.sh @@ -52,6 +52,8 @@ test_expect_success 'read with --get: xdg file exists and ~/.gitconfig exists' ' echo " name = read_gitconfig" >>.gitconfig && echo read_gitconfig >expected && git config --get user.name >actual && + test_cmp expected actual && + git config --global --get user.name >actual && test_cmp expected actual ' @@ -68,7 +70,8 @@ test_expect_success 'read with --list: xdg file exists and ~/.gitconfig exists' >.gitconfig && echo "[user]" >.gitconfig && echo " name = read_gitconfig" >>.gitconfig && - echo user.name=read_gitconfig >expected && + echo user.name=read_config >expected && + echo user.name=read_gitconfig >>expected && git config --global --list >actual && test_cmp expected actual '