Skip to content

Commit 2bfa826

Browse files
committed
fix: don't abort the fetch on a gitlink with no .gitmodules entry (#1380)
git submodule update --init --recursive and git submodule foreach both exit 128 as soon as they hit a gitlink (mode 160000) that has no matching .gitmodules entry -- e.g. an accidentally committed `git worktree` directory or nested checkout. Since dfetch has no URL to fetch such a gitlink from and nothing to report about it, aborting the whole fetch (or the whole `dfetch import`) over it serves no one. Add dfetch/vcs/git_gitlinks.py, which strips these orphan gitlinks from the index (via `git update-index --force-remove`) before either submodule command runs. `.gitmodules` is parsed via `git config --file ... --get-regexp path`, the same git-delegated approach already used for submodule URLs, rather than a custom parser. This leaves an out-of-scope gitlink's directory unmaterialized (as sparse-checkout already left it) and an in-scope one as an empty placeholder, and lets both commands proceed over the submodules that remain. Applied at both call sites that can hit this: GitLocalRepo.checkout_ version (a project fetch) and GitLocalRepo.submodules (used directly by `dfetch import` to scan the superproject) -- the latter was an equivalent, previously unreported crash with the same root cause. Extracting this into its own module also keeps dfetch/vcs/git.py under pylint's 1000-line module limit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DziAso23sxMgeTSUmzkus8
1 parent c2c7ee2 commit 2bfa826

5 files changed

Lines changed: 116 additions & 0 deletions

File tree

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
Release 0.14.4 (unreleased)
2+
====================================
3+
4+
* Fix a gitlink with no ``.gitmodules`` entry aborting fetches outside ``src`` (#1380)
5+
16
Release 0.14.3 (released 2026-06-25)
27
====================================
38

dfetch/vcs/git.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
strip_glob_prefix,
2424
unique_parent_dirs,
2525
)
26+
from dfetch.vcs import git_gitlinks
2627
from dfetch.vcs.git_types import CheckoutOptions, Submodule
2728
from dfetch.vcs.patch import Patch, PatchType
2829

@@ -524,6 +525,8 @@ def checkout_version(
524525
if options.eol is not None:
525526
self._renormalize_eol()
526527

528+
git_gitlinks.drop_orphan_gitlinks()
529+
527530
run_on_cmdline(
528531
logger,
529532
["git", "submodule", "update", "--init", "--recursive"],
@@ -843,6 +846,8 @@ def untracked_files_patch(self, ignore: Sequence[str] | None = None) -> Patch:
843846
@staticmethod
844847
def submodules() -> list[Submodule]:
845848
"""Get a list of submodules in the current directory."""
849+
git_gitlinks.drop_orphan_gitlinks()
850+
846851
result = run_on_cmdline(
847852
logger,
848853
[

dfetch/vcs/git_gitlinks.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Detect and neutralize gitlinks with no matching .gitmodules entry.
2+
3+
Some repositories contain gitlinks (mode ``160000``) that have no matching
4+
entry in ``.gitmodules`` -- for example an accidentally committed
5+
``git worktree`` directory or nested checkout. Both ``git submodule update
6+
--init --recursive`` and ``git submodule foreach`` abort outright as soon as
7+
they encounter such a gitlink, even though dfetch has no URL to fetch it
8+
from and nothing useful to report about it anyway.
9+
"""
10+
11+
import os
12+
import re
13+
14+
from dfetch.log import get_logger
15+
from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline
16+
17+
GIT_MODULES_FILE = ".gitmodules"
18+
19+
logger = get_logger(__name__)
20+
21+
22+
def gitlink_paths() -> list[str]:
23+
"""List the paths of all gitlinks (mode 160000) in the current index."""
24+
result = run_on_cmdline(logger, ["git", "ls-files", "-s", "-z"])
25+
paths = []
26+
for entry in result.stdout.decode().split("\0"):
27+
if not entry:
28+
continue
29+
meta, _, path = entry.partition("\t")
30+
if meta.split()[0] == "160000":
31+
paths.append(path)
32+
return paths
33+
34+
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()
39+
try:
40+
result = run_on_cmdline(
41+
logger,
42+
["git", "config", "--file", GIT_MODULES_FILE, "--get-regexp", "path"],
43+
)
44+
except SubprocessCommandError:
45+
return set()
46+
return {
47+
match.group(1)
48+
for match in re.finditer(
49+
r"submodule\.(?:.*)\.path\s+(.*)", result.stdout.decode()
50+
)
51+
}
52+
53+
54+
def drop_orphan_gitlinks() -> None:
55+
"""Strip gitlinks with no .gitmodules entry from the index.
56+
57+
Such a gitlink has no declared URL, so dfetch can neither fetch it nor
58+
report it as a dependency. Removing it from the index leaves its
59+
directory (if checked out at all) as an empty placeholder and lets
60+
``git submodule update``/``foreach`` proceed over the submodules that
61+
remain, instead of aborting the whole operation.
62+
"""
63+
declared = declared_submodule_paths()
64+
for path in gitlink_paths():
65+
if path not in declared:
66+
logger.debug(
67+
"Gitlink '%s' has no '.gitmodules' entry; skipping it as a submodule",
68+
path,
69+
)
70+
run_on_cmdline(logger, ["git", "update-index", "--force-remove", path])

features/import-from-git.feature

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,26 @@ Feature: Importing submodules from an existing git repository
3232
repo-path: test-repo
3333
3434
"""
35+
36+
Scenario: A stray gitlink with no .gitmodules entry does not abort the import
37+
Given a git repo with the following submodules
38+
| path | url | revision |
39+
| ext/test-repo1 | https://github.com/dfetch-org/test-repo | e1fda19a57b873eb8e6ae37780594cbb77b70f1a |
40+
And a stray gitlink "some/worktree" is added with no .gitmodules entry
41+
When I run "dfetch import"
42+
Then it should generate the manifest 'dfetch.yaml'
43+
"""
44+
manifest:
45+
version: '0.0'
46+
47+
remotes:
48+
- name: github-com-dfetch-org
49+
url-base: https://github.com/dfetch-org
50+
51+
projects:
52+
- name: ext/test-repo1
53+
revision: e1fda19a57b873eb8e6ae37780594cbb77b70f1a
54+
branch: main
55+
repo-path: test-repo
56+
57+
"""

features/steps/git_steps.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ def step_impl(context, name=None):
6969
commit_all("Added submodules")
7070

7171

72+
@given('a stray gitlink "{gitlink_path}" is added with no .gitmodules entry')
73+
def step_impl(context, gitlink_path):
74+
sha = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip()
75+
# A gitlink (mode 160000) with no corresponding .gitmodules entry, e.g. as
76+
# left behind by an accidentally committed `git worktree` or nested checkout.
77+
subprocess.check_call(
78+
["git", "update-index", "--add", "--cacheinfo", f"160000,{sha},{gitlink_path}"]
79+
)
80+
subprocess.check_call(
81+
["git", "commit", "-m", "Added a stray gitlink with no .gitmodules entry"]
82+
)
83+
84+
7285
@given(
7386
'a git-repository "{name}" with "{src_dir}" and a stray gitlink "{gitlink_path}" outside it'
7487
)

0 commit comments

Comments
 (0)