Skip to content

Commit 2d7f861

Browse files
authored
Merge pull request #2196 from gitpython-developers/fix-windows-path-handling
Fix Windows path handling on Python 3.13+
2 parents f67029c + 308d371 commit 2d7f861

17 files changed

Lines changed: 332 additions & 115 deletions

File tree

.basedpyright/baseline.json

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -887,22 +887,6 @@
887887
"lineCount": 1
888888
}
889889
},
890-
{
891-
"code": "reportPossiblyUnboundVariable",
892-
"range": {
893-
"startColumn": 22,
894-
"endColumn": 37,
895-
"lineCount": 1
896-
}
897-
},
898-
{
899-
"code": "reportPossiblyUnboundVariable",
900-
"range": {
901-
"startColumn": 22,
902-
"endColumn": 37,
903-
"lineCount": 1
904-
}
905-
},
906890
{
907891
"code": "reportReturnType",
908892
"range": {

.github/workflows/pythonpackage.yml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,6 @@ jobs:
2525
python-version: "3.14t"
2626
- os-type: macos
2727
python-version: "3.15t"
28-
- os-type: windows
29-
python-version: "3.13" # FIXME: Fix and enable Python 3.13-3.15 on Windows (#1955).
30-
- os-type: windows
31-
python-version: "3.14"
32-
- os-type: windows
33-
python-version: "3.14t"
34-
- os-type: windows
35-
python-version: "3.15"
36-
- os-type: windows
37-
python-version: "3.15t"
3828
include:
3929
- os-ver: latest
4030
- os-type: ubuntu

git/config.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -577,8 +577,12 @@ def _all_items(section: str) -> List[Tuple[str, str]]:
577577

578578
if keyword in ["gitdir", "gitdir/i"]:
579579
value = osp.expanduser(value)
580+
git_dir = os.fspath(self._repo.git_dir) if self._repo.git_dir else None
581+
if sys.platform == "win32":
582+
git_dir = git_dir.replace("\\", "/") if git_dir else None
580583

581-
if not any(value.startswith(s) for s in ["./", "/"]):
584+
drive, _tail = osp.splitdrive(value)
585+
if not drive and not any(value.startswith(s) for s in ["./", "/"]):
582586
value = "**/" + value
583587
if value.endswith("/"):
584588
value += "**"
@@ -590,9 +594,8 @@ def _all_items(section: str) -> List[Tuple[str, str]]:
590594
lambda m: f"[{m.group().lower()!r}{m.group().upper()!r}]",
591595
value,
592596
)
593-
if self._repo.git_dir:
594-
if fnmatch.fnmatchcase(os.fspath(self._repo.git_dir), value):
595-
paths += _all_items(section)
597+
if git_dir and fnmatch.fnmatchcase(git_dir, value):
598+
paths += _all_items(section)
596599

597600
elif keyword == "onbranch":
598601
try:

git/index/base.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
LockedFD,
3535
join_path_native,
3636
file_contents_ro,
37+
_is_path_rooted,
38+
_to_relative_path,
3739
to_native_path_linux,
3840
unbare_repo,
3941
to_bin_sha,
@@ -58,6 +60,7 @@
5860
Any,
5961
BinaryIO,
6062
Callable,
63+
cast,
6164
Dict,
6265
Generator,
6366
IO,
@@ -655,16 +658,12 @@ def _to_relative_path(self, path: PathLike) -> PathLike:
655658
656659
:raise ValueError:
657660
"""
658-
if not osp.isabs(path):
659-
return path
660661
if self.repo.bare:
661-
raise InvalidGitRepositoryError("require non-bare repository")
662-
if not osp.normpath(path).startswith(str(self.repo.working_tree_dir)):
663-
raise ValueError("Absolute path %r is not in git repository at %r" % (path, self.repo.working_tree_dir))
664-
result = os.path.relpath(path, self.repo.working_tree_dir)
665-
if os.fspath(path).endswith(os.sep) and not result.endswith(os.sep):
666-
result += os.sep
667-
return result
662+
drive, _tail = osp.splitdrive(os.fspath(path))
663+
if drive or _is_path_rooted(path):
664+
raise InvalidGitRepositoryError("paths with a drive or root require a non-bare repository")
665+
return path
666+
return _to_relative_path(cast(PathLike, self.repo.working_tree_dir), path)
668667

669668
def _preprocess_add_items(
670669
self, items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]]

git/objects/submodule/base.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from git.util import (
3030
IterableList,
3131
RemoteProgress,
32+
_to_relative_path,
3233
join_path_native,
3334
rmtree,
3435
to_native_path_linux,
@@ -392,23 +393,14 @@ def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike:
392393
:raise ValueError:
393394
If path is not contained in the parent repository's working tree.
394395
"""
395-
path = to_native_path_linux(path)
396+
if parent_repo.working_tree_dir:
397+
path = _to_relative_path(parent_repo.working_tree_dir, path)
398+
else:
399+
path = to_native_path_linux(path)
396400
if path.endswith("/"):
397401
path = path[:-1]
398-
# END handle trailing slash
399-
400-
if osp.isabs(path) and parent_repo.working_tree_dir:
401-
working_tree_linux = to_native_path_linux(parent_repo.working_tree_dir)
402-
if not path.startswith(working_tree_linux):
403-
raise ValueError(
404-
"Submodule checkout path '%s' needs to be within the parents repository at '%s'"
405-
% (working_tree_linux, path)
406-
)
407-
path = path[len(working_tree_linux.rstrip("/")) + 1 :]
408-
if not path:
409-
raise ValueError("Absolute submodule path '%s' didn't yield a valid relative path" % path)
410-
# END verify converted relative path makes sense
411-
# END convert to a relative path
402+
if not path or path == ".":
403+
raise ValueError("Submodule checkout path must not be the repository root")
412404

413405
return path
414406

git/refs/symbolic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def _get_validated_path(base: PathLike, path: PathLike) -> str:
119119
common_path = os.path.commonpath([base_path, abs_path])
120120
except ValueError as e:
121121
raise ValueError("Reference path %r escapes the repository" % path) from e
122-
if os.path.normcase(common_path) != os.path.normcase(base_path):
122+
if common_path != base_path:
123123
raise ValueError("Reference path %r escapes the repository" % path)
124124
return abs_path
125125

git/repo/base.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -961,8 +961,7 @@ def _get_alternates(self) -> List[str]:
961961
:return:
962962
List of strings being pathnames of alternates
963963
"""
964-
if self.git_dir:
965-
alternates_path = osp.join(self.git_dir, "objects", "info", "alternates")
964+
alternates_path = osp.join(self.common_dir, "objects", "info", "alternates")
966965

967966
if osp.exists(alternates_path):
968967
with open(alternates_path, "rb") as f:

git/util.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,68 @@ def join_path_native(a: PathLike, *p: PathLike) -> PathLike:
315315
return to_native_path(join_path(a, *p))
316316

317317

318+
def _is_path_rooted(path: PathLike) -> bool:
319+
r"""Whether ``path`` has a root, including one encoded in a UNC drive.
320+
321+
On Windows, ``\directory`` is rooted on the current drive without being
322+
absolute, while ``C:\directory`` has both a drive and a root. In contrast,
323+
``directory`` and the drive-relative ``C:directory`` have no root.
324+
UNC paths are rooted: ``\\server\share`` stores the share in the drive
325+
returned by :func:`os.path.splitdrive`, while ``\\server\share\directory``
326+
additionally has a rooted tail.
327+
On POSIX, which has no drive concept, this simply distinguishes absolute
328+
paths such as ``/directory`` from relative paths such as ``directory``.
329+
"""
330+
drive, tail = osp.splitdrive(os.fspath(path))
331+
separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep)
332+
return tail.startswith(separators) or drive.startswith(separators)
333+
334+
335+
def _to_relative_path(root: PathLike, path: PathLike) -> str:
336+
r"""Return a normalized Git-style path confined to ``root``.
337+
338+
A Windows path such as ``\directory`` is rooted but not absolute. Resolve it
339+
against the drive of ``root`` rather than treating it as relative to ``root``.
340+
Drive-relative paths such as ``C:directory`` are rejected because their meaning
341+
depends on process-global per-drive state.
342+
343+
For example, with ``root`` set to ``C:\repo`` on Windows:
344+
345+
* ``directory\file`` -> ``directory/file``
346+
* ``directory\`` -> ``directory/``
347+
* ``C:\repo\directory\file`` -> ``directory/file``
348+
* ``\repo\directory\file`` -> ``directory/file``
349+
* ``C:directory\file`` -> :exc:`ValueError`
350+
* ``C:\other\file`` -> :exc:`ValueError`
351+
352+
On POSIX, ``/repo/directory/file`` under ``/repo`` similarly becomes
353+
``directory/file``. A trailing separator is preserved as a Git-style ``/``.
354+
"""
355+
path_str = os.fspath(path)
356+
if not path_str:
357+
return path_str
358+
359+
drive, _tail = osp.splitdrive(path_str)
360+
rooted = _is_path_rooted(path_str)
361+
if drive and not rooted:
362+
raise ValueError("Drive-relative path %r is not supported" % path_str)
363+
364+
root_abs = osp.abspath(os.fspath(root))
365+
path_abs = osp.abspath(osp.join(root_abs, path_str))
366+
try:
367+
common_path = osp.commonpath([root_abs, path_abs])
368+
except ValueError as e:
369+
raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs)) from e
370+
if common_path != root_abs:
371+
raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs))
372+
373+
relative_path = to_native_path_linux(osp.relpath(path_abs, root_abs))
374+
separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep)
375+
if path_str.endswith(separators) and relative_path != "." and not relative_path.endswith("/"):
376+
relative_path += "/"
377+
return relative_path
378+
379+
318380
def assure_directory_exists(path: PathLike, is_file: bool = False) -> bool:
319381
"""Make sure that the directory pointed to by path exists.
320382

test/test_commit.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,14 +276,14 @@ def test_iteration(self):
276276
assert ltd_commits and len(ltd_commits) < len(all_commits)
277277

278278
# Show commits of multiple paths, resulting in a union of commits.
279-
less_ltd_commits = list(Commit.iter_items(self.rorepo, "master", paths=("CHANGES", "AUTHORS")))
279+
less_ltd_commits = list(Commit.iter_items(self.rorepo, "HEAD", paths=("CHANGES", "AUTHORS")))
280280
assert len(ltd_commits) < len(less_ltd_commits)
281281

282282
class Child(Commit):
283283
def __init__(self, *args, **kwargs):
284284
super().__init__(*args, **kwargs)
285285

286-
child_commits = list(Child.iter_items(self.rorepo, "master", paths=("CHANGES", "AUTHORS")))
286+
child_commits = list(Child.iter_items(self.rorepo, "HEAD", paths=("CHANGES", "AUTHORS")))
287287
assert type(child_commits[0]) is Child
288288

289289
def test_iter_items(self):
@@ -536,7 +536,7 @@ def test_trailers(self):
536536
),
537537
]
538538
for msg in msgs:
539-
commit = copy.copy(self.rorepo.commit("master"))
539+
commit = copy.copy(self.rorepo.commit("HEAD"))
540540
commit.message = msg
541541
assert commit.trailers_list == [
542542
(KEY_1, VALUE_1_1),
@@ -559,13 +559,13 @@ def test_trailers(self):
559559
]
560560

561561
for msg in msgs:
562-
commit = copy.copy(self.rorepo.commit("master"))
562+
commit = copy.copy(self.rorepo.commit("HEAD"))
563563
commit.message = msg
564564
assert commit.trailers_list == []
565565
assert commit.trailers_dict == {}
566566

567567
# Check that only the last key value paragraph is evaluated.
568-
commit = copy.copy(self.rorepo.commit("master"))
568+
commit = copy.copy(self.rorepo.commit("HEAD"))
569569
commit.message = f"Subject\n\nMultiline\nBody\n\n{KEY_1}: {VALUE_1_1}\n\n{KEY_2}: {VALUE_2}\n"
570570
assert commit.trailers_list == [(KEY_2, VALUE_2)]
571571
assert commit.trailers_dict == {KEY_2: [VALUE_2]}

test/test_config.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from git import GitConfigParser
1616
from git.config import _OMD, cp
17-
from git.util import rmfile
17+
from git.util import cwd, rmfile
1818

1919
from test.lib import SkipTest, TestCase, fixture_path, with_rw_directory
2020

@@ -374,6 +374,22 @@ def test_config_relative_path_include(self, rw_dir):
374374
with GitConfigParser(relative_config_path, read_only=True) as cr:
375375
assert cr.get_value("included", "value") == "included"
376376

377+
@pytest.mark.skipif(os.name != "nt", reason="Specifically for Windows drive-rooted paths.")
378+
@with_rw_directory
379+
def test_config_drive_rooted_path_include(self, rw_dir):
380+
with cwd(rw_dir):
381+
included_path = osp.join(rw_dir, "included")
382+
with GitConfigParser(included_path, read_only=False) as cw:
383+
cw.set_value("included", "value", "included")
384+
385+
_drive, rooted_included_path = osp.splitdrive(included_path)
386+
config_path = osp.join(rw_dir, "config")
387+
with GitConfigParser(config_path, read_only=False) as cw:
388+
cw.set_value("include", "path", rooted_included_path)
389+
390+
with GitConfigParser(config_path, read_only=True) as cr:
391+
assert cr.get_value("included", "value") == "included"
392+
377393
@with_rw_directory
378394
def test_multiple_include_paths_with_same_key(self, rw_dir):
379395
"""Test that multiple 'path' entries under [include] are all respected.
@@ -411,15 +427,11 @@ def test_multiple_include_paths_with_same_key(self, rw_dir):
411427
assert cr.get_value("user", "name") == "from-inc1"
412428
assert cr.get_value("core", "bar") == "from-inc2"
413429

414-
@pytest.mark.xfail(
415-
sys.platform == "win32",
416-
reason='Second config._has_includes() assertion fails (for "config is included if path is matching git_dir")',
417-
raises=AssertionError,
418-
)
419430
@with_rw_directory
420431
def test_conditional_includes_from_git_dir(self, rw_dir):
421432
# Initiate repository path.
422433
git_dir = osp.join(rw_dir, "target1", "repo1")
434+
git_dir_pattern = git_dir.replace("\\", "/")
423435
os.makedirs(git_dir)
424436

425437
# Initiate mocked repository.
@@ -431,29 +443,42 @@ def test_conditional_includes_from_git_dir(self, rw_dir):
431443
template = '[includeIf "{}:{}"]\n path={}\n'
432444

433445
with open(path1, "w") as stream:
446+
# on Windows, this writes a backslash pattern.
434447
stream.write(template.format("gitdir", git_dir, path2))
435448

436449
# Ensure that config is ignored if no repo is set.
437450
with GitConfigParser(path1) as config:
438451
assert not config._has_includes()
439452
assert config._included_paths() == []
440453

441-
# Ensure that config is included if path is matching git_dir.
454+
# Git uses forward slashes in gitdir patterns on every platform:
455+
# backslashes escape the next pattern character rather than separate
456+
# path components. On Windows, GitPython therefore normalizes git_dir
457+
# to forward slashes but leaves this backslash pattern unchanged, so
458+
# the two do not match and no path is included.
459+
with GitConfigParser(path1, repo=repo, merge_includes=False) as config:
460+
expected_paths = [] if sys.platform == "win32" else [("path", path2)]
461+
assert config._included_paths() == expected_paths
462+
463+
# Ensure that Git's forward-slash syntax matches native Windows paths.
464+
with open(path1, "w") as stream:
465+
stream.write(template.format("gitdir", git_dir_pattern, path2))
466+
442467
with GitConfigParser(path1, repo=repo) as config:
443468
assert config._has_includes()
444469
assert config._included_paths() == [("path", path2)]
445470

446471
# Ensure that config is ignored if case is incorrect.
447472
with open(path1, "w") as stream:
448-
stream.write(template.format("gitdir", git_dir.upper(), path2))
473+
stream.write(template.format("gitdir", git_dir_pattern.upper(), path2))
449474

450475
with GitConfigParser(path1, repo=repo) as config:
451476
assert not config._has_includes()
452477
assert config._included_paths() == []
453478

454479
# Ensure that config is included if case is ignored.
455480
with open(path1, "w") as stream:
456-
stream.write(template.format("gitdir/i", git_dir.upper(), path2))
481+
stream.write(template.format("gitdir/i", git_dir_pattern.upper(), path2))
457482

458483
with GitConfigParser(path1, repo=repo) as config:
459484
assert config._has_includes()
@@ -483,6 +508,20 @@ def test_conditional_includes_from_git_dir(self, rw_dir):
483508
assert config._has_includes()
484509
assert config._included_paths() == [("path", path2)]
485510

511+
@with_rw_directory
512+
def test_conditional_includes_do_not_treat_backslashes_as_separators(self, rw_dir):
513+
git_dir = osp.join(rw_dir, "target", "repo")
514+
repo = mock.Mock(git_dir=git_dir)
515+
config_path = osp.join(rw_dir, "config")
516+
included_path = osp.join(rw_dir, "included")
517+
pattern = git_dir.replace("\\", "/").replace("/target/repo", R"/target\repo")
518+
519+
with open(config_path, "w") as stream:
520+
stream.write(f'[includeIf "gitdir:{pattern}"]\n path={included_path}\n')
521+
522+
with GitConfigParser(config_path, repo=repo, merge_includes=False) as config:
523+
assert config._included_paths() == []
524+
486525
@with_rw_directory
487526
def test_conditional_includes_from_branch_name(self, rw_dir):
488527
# Initiate mocked branch.

0 commit comments

Comments
 (0)