From b83fcb8bbe44bfba7f78e5e68cb3be3571f165ce Mon Sep 17 00:00:00 2001 From: b84330808 Date: Fri, 23 Oct 2026 21:55:35 +0900 Subject: [PATCH] fix(config): match `~/` gitdir includes on Windows On Windows, `GitConfigParser` never applied an `[includeIf "gitdir:~/..."]` section, although `git config` does. `os.path.expanduser` returns the home directory with backslashes, so `gitdir:~/work/` became the pattern `C:\Users\me/work/**`. GitPython compares gitdir patterns with the repository's `git_dir` written with forward slashes, and in a gitdir pattern a backslash escapes the next character instead of separating path components, so the pattern could never match. Git converts the home directory to forward slashes when it expands `~` on Windows (`interpolate_path()` in `path.c`, which `prepare_include_condition_pattern()` in `config.c` uses for gitdir conditions). A usual setup such as [includeIf "gitdir:~/work/"] path = ~/.gitconfig-work therefore made GitPython read `user.email` and other settings for a repository under `~/work` from the global configuration only, while Git read them from the included file. On Windows, a pattern that is `~` or starts with `~/` now gets the home directory with forward slashes. The rest of the pattern is unchanged, so a backslash written in the pattern still escapes the next character, as `test_conditional_includes_do_not_treat_backslashes_as_separators` expects. Other platforms and `~user` patterns keep the previous `os.path.expanduser` call. `test_conditional_includes_expand_home_in_git_dir_pattern` points the home directory at a temporary directory and checks that `gitdir:~/target/` matches a repository below it. On Windows it fails without this change and passes with it. On other platforms it passes either way. Validated on Windows 11 with Git for Windows 2.51.2 and Python 3.11.9: `test/test_config.py` passes (55 passed, 3 skipped); `ruff check`, `ruff format --check`, `mypy` and `basedpyright --warnings` report no issues. For a repository whose `.git/config` includes a file through `gitdir:~/...`, `git config --get user.email` returns the included value, and `Repo.config_reader("repository").get_value("user", "email")` now returns it too instead of finding no value. Co-Authored-By: Claude Opus 5.5 --- git/config.py | 7 ++++++- test/test_config.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/git/config.py b/git/config.py index 732d3658b..8d011ec8a 100644 --- a/git/config.py +++ b/git/config.py @@ -730,7 +730,12 @@ def _all_items(section: str) -> List[Tuple[str, str]]: value = match.group(2).strip() if keyword in ["gitdir", "gitdir/i"]: - value = osp.expanduser(value) + if sys.platform == "win32" and (value == "~" or value.startswith("~/")): + # Like Git, substitute the home directory with forward slashes, as a + # backslash in the pattern would escape the next character. + value = osp.expanduser("~").replace("\\", "/") + value[1:] + else: + value = osp.expanduser(value) git_dir = os.fspath(self._repo.git_dir) if self._repo.git_dir else None if sys.platform == "win32": git_dir = git_dir.replace("\\", "/") if git_dir else None diff --git a/test/test_config.py b/test/test_config.py index 49b8535c7..f7907e98e 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -989,6 +989,22 @@ def test_conditional_includes_do_not_treat_backslashes_as_separators(self, rw_di with GitConfigParser(config_path, repo=repo, merge_includes=False) as config: assert config._included_paths() == [] + @with_rw_directory + def test_conditional_includes_expand_home_in_git_dir_pattern(self, rw_dir): + git_dir = osp.join(rw_dir, "target", "repo", ".git") + repo = mock.Mock(git_dir=git_dir) + config_path = osp.join(rw_dir, "config") + included_path = osp.join(rw_dir, "included") + + with open(config_path, "w") as stream: + stream.write(f'[includeIf "gitdir:~/target/"]\n path={included_path}\n') + + # On Windows, the home directory has backslashes, but Git matches it as if it + # were written with forward slashes. + with mock.patch.dict(os.environ, {"HOME": rw_dir, "USERPROFILE": rw_dir}): + with GitConfigParser(config_path, repo=repo, merge_includes=False) as config: + assert config._included_paths() == [("path", included_path)] + @with_rw_directory def test_conditional_includes_from_branch_name(self, rw_dir): # Initiate mocked branch.