Skip to content

Commit 8c0966f

Browse files
codexByron
authored andcommitted
fix Windows path handling for Python 3.13+ (#1955) #(2072)
This commit also makes the test-suite independent of the presence of a local `master` branch. - make index and submodule path normalization distinguish drive-rooted, drive-relative, UNC, and device paths on Windows - confine normalized paths with `commonpath` instead of string-prefix checks - normalize `includeIf gitdir` patterns to Git-style separators - cover the other `isabs` consumers whose drive-rooted behavior correctly relies on Windows join semantics - remove the Windows 3.13-3.15 CI exclusions and add 3.13-3.15 to tox - make the pre-existing tests independent of a local `master` branch and linked-worktree-specific alternates For context, the classifiers mentioned in the issue #2072 were introduced by 904aa71 (`Declare support for Python 3.13-3.14`) and 7d5370d (`Add support for Python 3.15`), both included in 3.1.56. This PR supplies the Windows path work that those metadata changes did not include. - paths absolute on the current drive - drive-rooted paths such as `\directory` and `/directory` - drive-relative paths such as `C:directory` (rejected where repository confinement is required) - other-drive, UNC, and device paths - traversal and adjacent-prefix escape attempts - native and Git-style separators in conditional includes - config includes, submodule gitfiles, linked worktree metadata, clone destinations, and Cygwin-path conversion - Python 3.12.13, 3.13.14, 3.14.6, 3.14.6t, 3.15.0b4, and 3.15.0b4t installed locally with uv - full test set passed for every interpreter in an independent clone without a local `master` branch; daemon-free suites ran in parallel and `test_remote.py` ran serially because its static local daemon endpoint cannot be shared across processes - Ruff check and format - mypy - basedpyright - all pre-commit hooks
1 parent 04960cf commit 8c0966f

17 files changed

Lines changed: 296 additions & 114 deletions

File tree

.basedpyright/baseline.json

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -895,22 +895,6 @@
895895
"lineCount": 1
896896
}
897897
},
898-
{
899-
"code": "reportPossiblyUnboundVariable",
900-
"range": {
901-
"startColumn": 22,
902-
"endColumn": 37,
903-
"lineCount": 1
904-
}
905-
},
906-
{
907-
"code": "reportPossiblyUnboundVariable",
908-
"range": {
909-
"startColumn": 22,
910-
"endColumn": 37,
911-
"lineCount": 1
912-
}
913-
},
914898
{
915899
"code": "reportReturnType",
916900
"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: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -576,9 +576,11 @@ def _all_items(section: str) -> List[Tuple[str, str]]:
576576
value = match.group(2).strip()
577577

578578
if keyword in ["gitdir", "gitdir/i"]:
579-
value = osp.expanduser(value)
579+
value = osp.expanduser(value).replace("\\", "/")
580+
git_dir = os.fspath(self._repo.git_dir).replace("\\", "/") if self._repo.git_dir else None
580581

581-
if not any(value.startswith(s) for s in ["./", "/"]):
582+
drive, _tail = osp.splitdrive(value)
583+
if not drive and not any(value.startswith(s) for s in ["./", "/"]):
582584
value = "**/" + value
583585
if value.endswith("/"):
584586
value += "**"
@@ -590,9 +592,8 @@ def _all_items(section: str) -> List[Tuple[str, str]]:
590592
lambda m: f"[{m.group().lower()!r}{m.group().upper()!r}]",
591593
value,
592594
)
593-
if self._repo.git_dir:
594-
if fnmatch.fnmatchcase(os.fspath(self._repo.git_dir), value):
595-
paths += _all_items(section)
595+
if git_dir and fnmatch.fnmatchcase(git_dir, value):
596+
paths += _all_items(section)
596597

597598
elif keyword == "onbranch":
598599
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
@@ -28,6 +28,7 @@
2828
from git.util import (
2929
IterableList,
3030
RemoteProgress,
31+
_to_relative_path,
3132
join_path_native,
3233
rmtree,
3334
to_native_path_linux,
@@ -379,23 +380,14 @@ def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike:
379380
:raise ValueError:
380381
If path is not contained in the parent repository's working tree.
381382
"""
382-
path = to_native_path_linux(path)
383+
if parent_repo.working_tree_dir:
384+
path = _to_relative_path(parent_repo.working_tree_dir, path)
385+
else:
386+
path = to_native_path_linux(path)
383387
if path.endswith("/"):
384388
path = path[:-1]
385-
# END handle trailing slash
386-
387-
if osp.isabs(path) and parent_repo.working_tree_dir:
388-
working_tree_linux = to_native_path_linux(parent_repo.working_tree_dir)
389-
if not path.startswith(working_tree_linux):
390-
raise ValueError(
391-
"Submodule checkout path '%s' needs to be within the parents repository at '%s'"
392-
% (working_tree_linux, path)
393-
)
394-
path = path[len(working_tree_linux.rstrip("/")) + 1 :]
395-
if not path:
396-
raise ValueError("Absolute submodule path '%s' didn't yield a valid relative path" % path)
397-
# END verify converted relative path makes sense
398-
# END convert to a relative path
389+
if not path or path == ".":
390+
raise ValueError("Submodule checkout path must not be the repository root")
399391

400392
return path
401393

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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,65 @@ 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 component after any 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+
On POSIX, which has no drive concept, this simply distinguishes absolute
325+
paths such as ``/directory`` from relative paths such as ``directory``.
326+
"""
327+
_drive, tail = osp.splitdrive(os.fspath(path))
328+
separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep)
329+
return tail.startswith(separators)
330+
331+
332+
def _to_relative_path(root: PathLike, path: PathLike) -> str:
333+
r"""Return a normalized Git-style path confined to ``root``.
334+
335+
A Windows path such as ``\directory`` is rooted but not absolute. Resolve it
336+
against the drive of ``root`` rather than treating it as relative to ``root``.
337+
Drive-relative paths such as ``C:directory`` are rejected because their meaning
338+
depends on process-global per-drive state.
339+
340+
For example, with ``root`` set to ``C:\repo`` on Windows:
341+
342+
* ``directory\file`` -> ``directory/file``
343+
* ``directory\`` -> ``directory/``
344+
* ``C:\repo\directory\file`` -> ``directory/file``
345+
* ``\repo\directory\file`` -> ``directory/file``
346+
* ``C:directory\file`` -> :exc:`ValueError`
347+
* ``C:\other\file`` -> :exc:`ValueError`
348+
349+
On POSIX, ``/repo/directory/file`` under ``/repo`` similarly becomes
350+
``directory/file``. A trailing separator is preserved as a Git-style ``/``.
351+
"""
352+
path_str = os.fspath(path)
353+
if not path_str:
354+
return path_str
355+
356+
drive, _tail = osp.splitdrive(path_str)
357+
rooted = _is_path_rooted(path_str)
358+
if drive and not rooted:
359+
raise ValueError("Drive-relative path %r is not supported" % path_str)
360+
361+
root_abs = osp.abspath(os.fspath(root))
362+
path_abs = osp.abspath(osp.join(root_abs, path_str))
363+
try:
364+
common_path = osp.commonpath([root_abs, path_abs])
365+
except ValueError as e:
366+
raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs)) from e
367+
if common_path != root_abs:
368+
raise ValueError("Path %r is not in repository at %r" % (path_str, root_abs))
369+
370+
relative_path = to_native_path_linux(osp.relpath(path_abs, root_abs))
371+
separators = (os.sep,) if os.altsep is None else (os.sep, os.altsep)
372+
if path_str.endswith(separators) and relative_path != "." and not relative_path.endswith("/"):
373+
relative_path += "/"
374+
return relative_path
375+
376+
318377
def assure_directory_exists(path: PathLike, is_file: bool = False) -> bool:
319378
"""Make sure that the directory pointed to by path exists.
320379

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: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,13 @@
77
import io
88
import os
99
import os.path as osp
10-
import sys
1110
from unittest import mock
1211

1312
import pytest
1413

1514
from git import GitConfigParser
1615
from git.config import _OMD, cp
17-
from git.util import rmfile
16+
from git.util import cwd, rmfile
1817

1918
from test.lib import SkipTest, TestCase, fixture_path, with_rw_directory
2019

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

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

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-
)
419429
@with_rw_directory
420430
def test_conditional_includes_from_git_dir(self, rw_dir):
421431
# Initiate repository path.
@@ -443,6 +453,14 @@ def test_conditional_includes_from_git_dir(self, rw_dir):
443453
assert config._has_includes()
444454
assert config._included_paths() == [("path", path2)]
445455

456+
# Ensure that Git's forward-slash syntax matches native Windows paths.
457+
with open(path1, "w") as stream:
458+
stream.write(template.format("gitdir", git_dir.replace("\\", "/"), path2))
459+
460+
with GitConfigParser(path1, repo=repo) as config:
461+
assert config._has_includes()
462+
assert config._included_paths() == [("path", path2)]
463+
446464
# Ensure that config is ignored if case is incorrect.
447465
with open(path1, "w") as stream:
448466
stream.write(template.format("gitdir", git_dir.upper(), path2))

0 commit comments

Comments
 (0)