Skip to content

Commit 8caa158

Browse files
committed
fix: address CodeRabbit findings on orphan-gitlink stripping
Three correctness issues found in review of PR #1385: - declared_submodule_paths() swallowed every SubprocessCommandError from `git config --file .gitmodules --get-regexp`, including exit 128 for a malformed .gitmodules -- indistinguishable from the documented exit 1 for "no matching lines". A malformed .gitmodules would make drop_orphan_gitlinks() strip every gitlink, including legitimately declared ones. Now only exit 1 is treated as empty; anything else propagates. - declared_submodule_paths() only checked for a `path` entry, so a stanza declaring `path` but no `url` was treated as "declared" and kept, even though `git submodule update --init --recursive` still fails on it with the same "No url found for submodule path" error the fix exists to avoid. Now a path only counts as declared when a matching `url` entry exists too. - The `git update-index --force-remove <path>` call had no `--` terminator, so a gitlink path starting with `-` (e.g. a repo containing a path literally named `--cacheinfo`) could be parsed as an option instead of a path and fail. Added `--`. Added tests/test_git_gitlinks.py covering all three; confirmed each fails against the pre-fix code and passes against the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DziAso23sxMgeTSUmzkus8
1 parent 2bfa826 commit 8caa158

3 files changed

Lines changed: 138 additions & 10 deletions

File tree

dfetch/vcs/git_gitlinks.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,45 @@ def gitlink_paths() -> list[str]:
3232
return paths
3333

3434

35-
def declared_submodule_paths() -> set[str]:
36-
"""Return the submodule paths declared in .gitmodules, if any."""
37-
if not os.path.isfile(GIT_MODULES_FILE):
38-
return set()
35+
def _submodule_config_values(key: str) -> dict[str, str]:
36+
"""Return {submodule name: value} for a .gitmodules key ("path" or "url").
37+
38+
Args:
39+
key: The per-submodule config key to read, e.g. "path" or "url".
40+
"""
3941
try:
4042
result = run_on_cmdline(
4143
logger,
42-
["git", "config", "--file", GIT_MODULES_FILE, "--get-regexp", "path"],
44+
["git", "config", "--file", GIT_MODULES_FILE, "--get-regexp", key],
4345
)
44-
except SubprocessCommandError:
45-
return set()
46+
except SubprocessCommandError as exc:
47+
# get-regexp documents exit status 1 for "no matching lines"; anything
48+
# else (e.g. a malformed .gitmodules) must not be mistaken for that.
49+
if exc.returncode == 1:
50+
return {}
51+
raise
4652
return {
47-
match.group(1)
53+
match.group(1): match.group(2)
4854
for match in re.finditer(
49-
r"submodule\.(?:.*)\.path\s+(.*)", result.stdout.decode()
55+
rf"submodule\.(.*)\.{key}\s+(.*)", result.stdout.decode()
5056
)
5157
}
5258

5359

60+
def declared_submodule_paths() -> set[str]:
61+
"""Return the paths of .gitmodules submodules that also declare a url.
62+
63+
A stanza with a ``path`` but no ``url`` cannot be initialized by
64+
``git submodule update`` either, so it is treated the same as an orphan
65+
gitlink rather than as "declared".
66+
"""
67+
if not os.path.isfile(GIT_MODULES_FILE):
68+
return set()
69+
paths = _submodule_config_values("path")
70+
urls = _submodule_config_values("url")
71+
return {path for name, path in paths.items() if name in urls}
72+
73+
5474
def drop_orphan_gitlinks() -> None:
5575
"""Strip gitlinks with no .gitmodules entry from the index.
5676
@@ -67,4 +87,6 @@ def drop_orphan_gitlinks() -> None:
6787
"Gitlink '%s' has no '.gitmodules' entry; skipping it as a submodule",
6888
path,
6989
)
70-
run_on_cmdline(logger, ["git", "update-index", "--force-remove", path])
90+
run_on_cmdline(
91+
logger, ["git", "update-index", "--force-remove", "--", path]
92+
)

tests/test_git_gitlinks.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Test dfetch.vcs.git_gitlinks."""
2+
3+
# mypy: ignore-errors
4+
# flake8: noqa
5+
6+
import subprocess
7+
8+
import pytest
9+
10+
from dfetch.util.cmdline import SubprocessCommandError
11+
from dfetch.vcs import git_gitlinks
12+
13+
14+
def _init_git_repo(path):
15+
"""Initialize a real git repo at *path* with a committer identity set.
16+
17+
Args:
18+
path: Directory to initialize as a git repo.
19+
"""
20+
subprocess.check_call(["git", "init", "--quiet"], cwd=path)
21+
subprocess.check_call(["git", "config", "user.email", "you@example.com"], cwd=path)
22+
subprocess.check_call(["git", "config", "user.name", "John Doe"], cwd=path)
23+
subprocess.check_call(["git", "config", "commit.gpgsign", "false"], cwd=path)
24+
25+
26+
def _add_gitlink(path, gitlink_path):
27+
"""Stage a gitlink (mode 160000) at *gitlink_path* using the repo's own HEAD sha."""
28+
sha = (
29+
subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=path).decode().strip()
30+
)
31+
subprocess.check_call(
32+
[
33+
"git",
34+
"update-index",
35+
"--add",
36+
"--cacheinfo",
37+
f"160000,{sha},{gitlink_path}",
38+
],
39+
cwd=path,
40+
)
41+
42+
43+
def test_declared_submodule_paths_requires_a_url(tmp_path, monkeypatch):
44+
"""A .gitmodules stanza with a path but no url must not count as declared.
45+
46+
git submodule update --init --recursive fails on such a stanza with the
47+
same "No url found for submodule path" error as a fully undeclared
48+
gitlink, so it must be treated the same way: as an orphan to drop.
49+
"""
50+
_init_git_repo(tmp_path)
51+
(tmp_path / "root").write_text("root")
52+
subprocess.check_call(["git", "add", "root"], cwd=tmp_path)
53+
subprocess.check_call(["git", "commit", "-qm", "initial"], cwd=tmp_path)
54+
_add_gitlink(tmp_path, "orphan")
55+
(tmp_path / ".gitmodules").write_text('[submodule "orphan"]\n\tpath = orphan\n')
56+
subprocess.check_call(["git", "add", ".gitmodules"], cwd=tmp_path)
57+
subprocess.check_call(
58+
["git", "commit", "-qm", "path-only declaration"], cwd=tmp_path
59+
)
60+
61+
monkeypatch.chdir(tmp_path)
62+
assert git_gitlinks.declared_submodule_paths() == set()
63+
64+
65+
def test_declared_submodule_paths_propagates_malformed_gitmodules(
66+
tmp_path, monkeypatch
67+
):
68+
"""A malformed .gitmodules must raise, not be silently treated as 'no submodules'.
69+
70+
Swallowing every SubprocessCommandError here would make
71+
drop_orphan_gitlinks() strip every gitlink -- including legitimately
72+
declared ones -- whenever .gitmodules simply fails to parse.
73+
"""
74+
_init_git_repo(tmp_path)
75+
(tmp_path / ".gitmodules").write_text('[submodule "broken"\n')
76+
77+
monkeypatch.chdir(tmp_path)
78+
with pytest.raises(SubprocessCommandError):
79+
git_gitlinks.declared_submodule_paths()
80+
81+
82+
def test_drop_orphan_gitlinks_handles_leading_dash_path(tmp_path, monkeypatch):
83+
"""A gitlink path starting with '-' must not be parsed as a git option.
84+
85+
Without a '--' terminator before the path, `git update-index
86+
--force-remove --cacheinfo` would try to parse '--cacheinfo' as an
87+
option instead of a path and fail.
88+
"""
89+
_init_git_repo(tmp_path)
90+
(tmp_path / "root").write_text("root")
91+
subprocess.check_call(["git", "add", "root"], cwd=tmp_path)
92+
subprocess.check_call(["git", "commit", "-qm", "initial"], cwd=tmp_path)
93+
_add_gitlink(tmp_path, "--cacheinfo")
94+
95+
monkeypatch.chdir(tmp_path)
96+
git_gitlinks.drop_orphan_gitlinks()
97+
98+
remaining = subprocess.check_output(
99+
["git", "ls-files", "-s"], cwd=tmp_path
100+
).decode()
101+
assert "--cacheinfo" not in remaining

tests/test_git_vcs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ def test_filter_submodules_sibling_of_src_not_removed(tmp_path, monkeypatch):
226226

227227

228228
def _init_git_repo(path):
229+
"""Initialize a real git repo at *path* with a committer identity set.
230+
231+
Args:
232+
path: Directory to initialize as a git repo.
233+
"""
229234
subprocess.check_call(["git", "init", "--initial-branch=main", "--quiet"], cwd=path)
230235
subprocess.check_call(["git", "config", "user.email", "you@example.com"], cwd=path)
231236
subprocess.check_call(["git", "config", "user.name", "John Doe"], cwd=path)

0 commit comments

Comments
 (0)