Skip to content

Commit b532a50

Browse files
codexByron
authored andcommitted
fix(repo): preserve literal separate Git directory paths
`Repo._clone()` expanded `separate_git_dir` before invoking Git, so metadata could be written under a different directory name from the one requested. Preserve literal paths to address `GHSA-fx3j-rwgx-fr94`. Disable expansion in the shared `Git.polish_url()` call. Normalize path-like arguments with `os.fspath()` first so `pathlib.Path` and other `os.PathLike` implementations remain supported. Existing path separator conversion still applies. Add local regression coverage for both clone APIs, three path argument types, variable-like names, and recursive submodule initialization. Check the metadata location, usable submodule commit, and debug output. Git reference: checkout `d38352cd43ab9745686d697872408bc3249a153f`, `builtin/clone.c` and `t/t5601-clone.sh`'s separate-git-dir tests. Native Git 2.54.0 also preserved all three literal variable forms. Validation on Python 3.14.7: - New regression cases: 14 failed before the fix; all 22 pass after it. - Clone/submodule suites: 261 passed, 4 skipped, and 1 expected failure. All 16 local configuration failures passed on targeted rerun with per-process `commit.gpgsign=false`, `tag.gpgsign=false`, and `init.defaultBranch=master`. - `ruff check` and `ruff format --check` on changed files pass. - `mypy` passes for all 46 checked source files.
1 parent 71b9545 commit b532a50

3 files changed

Lines changed: 48 additions & 1 deletion

File tree

‎git/repo/base.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1537,7 +1537,7 @@ def _clone(
15371537
clone_path = Git.polish_url(path) if Git.is_cygwin() and "bare" in kwargs else path
15381538
sep_dir = kwargs.get("separate_git_dir")
15391539
if sep_dir:
1540-
kwargs["separate_git_dir"] = Git.polish_url(sep_dir)
1540+
kwargs["separate_git_dir"] = Git.polish_url(os.fspath(sep_dir), expand_vars=False)
15411541
multi = None
15421542
if multi_options:
15431543
multi = shlex.split(" ".join(multi_options))

‎test/test_clone.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@
2121
import pytest
2222

2323

24+
@pytest.mark.parametrize("clone_method", ["clone", "clone_from"])
25+
@pytest.mark.parametrize("path_type", [str, Path, PathLikeMock])
26+
@pytest.mark.parametrize("name", ["$GITPYTHON_TEST_SECRET", "${GITPYTHON_TEST_SECRET}", "%GITPYTHON_TEST_SECRET%"])
27+
def test_clone_preserves_literal_separate_git_dir(tmp_path, monkeypatch, caplog, clone_method, path_type, name):
28+
monkeypatch.setenv("GITPYTHON_TEST_SECRET", "sensitive-value")
29+
caplog.set_level("DEBUG", logger="git.cmd")
30+
separate_git_dir = tmp_path / name
31+
options = {"separate_git_dir": path_type(str(separate_git_dir)), "allow_unsafe_options": True}
32+
33+
with Repo.init(tmp_path / "source") as source:
34+
if clone_method == "clone":
35+
cloned = source.clone(tmp_path / "clone", **options)
36+
else:
37+
cloned = Repo.clone_from(source.git_dir, tmp_path / "clone", **options)
38+
with cloned:
39+
assert (separate_git_dir / "HEAD").is_file()
40+
assert separate_git_dir.samefile(cloned.git_dir)
41+
42+
assert not (tmp_path / "sensitive-value").exists()
43+
assert "sensitive-value" not in caplog.text
44+
45+
2446
class TestClone(TestBase):
2547
@with_rw_directory
2648
def test_checkout_in_non_empty_dir(self, rw_dir):

‎test/test_submodule.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ def _patch_git_config(name, value):
5252
yield
5353

5454

55+
@pytest.mark.parametrize(
56+
"name", ["module", "$GITPYTHON_TEST_SECRET", "prefix-${GITPYTHON_TEST_SECRET}-suffix", "%GITPYTHON_TEST_SECRET%"]
57+
)
58+
def test_submodule_update_preserves_literal_name(tmp_path, monkeypatch, caplog, name):
59+
monkeypatch.setenv("GITPYTHON_TEST_SECRET", "sensitive-value")
60+
caplog.set_level("DEBUG", logger="git.cmd")
61+
with git.Repo.init(tmp_path / "source") as source, git.Repo.init(tmp_path / "parent") as parent:
62+
source.git.symbolic_ref("HEAD", "refs/heads/master")
63+
source.index.commit("Initial commit")
64+
with _patch_git_config("protocol.file.allow", "always"):
65+
parent.git.submodule("add", "--name", name, source.working_tree_dir, "module")
66+
parent.index.commit("Add submodule")
67+
68+
with git.Repo.clone_from(parent.working_tree_dir, tmp_path / "clone") as clone:
69+
clone.submodule_update(init=True, recursive=True)
70+
71+
modules_dir = Path(clone.git_dir, "modules")
72+
assert {path.name for path in modules_dir.iterdir()} == {name}
73+
with clone.submodules[0].module() as module:
74+
assert (modules_dir / name).samefile(module.git_dir)
75+
assert module.head.commit == source.head.commit
76+
77+
assert "sensitive-value" not in caplog.text
78+
79+
5580
@pytest.fixture
5681
def movable_submodule(tmp_path):
5782
"""Create a committed local submodule whose logical name stays fixed when moved."""

0 commit comments

Comments
 (0)