From 4fd80ab0be83ac932541eb69264995c62b8c8304 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 07:11:41 +0000 Subject: [PATCH 1/3] test: reproduce #1428 - root-level dir matching license glob gets vendored The 'src:' sparse-checkout keeps license glob patterns (licen[cs]e*, copying*, copyright*) without a trailing slash, so a root-level directory whose name happens to match (e.g. "licensecore") is pulled in wholesale alongside the requested subtree. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KcHRu7KByphASCkWkmfgYc --- features/keep-license-in-project.feature | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/features/keep-license-in-project.feature b/features/keep-license-in-project.feature index 2e993236b..4a4ef131f 100644 --- a/features/keep-license-in-project.feature +++ b/features/keep-license-in-project.feature @@ -36,6 +36,33 @@ Feature: Keep license in project dfetch.yaml """ + Scenario: Root-level folder matching a license glob is not vendored alongside 'src:' + Given the manifest 'dfetch.yaml' in MyProject + """ + manifest: + version: 0.0 + projects: + - name: SomeProjectWithLicenseLikeFolder + url: some-remote-server/SomeProjectWithLicenseLikeFolder.git + src: SomeFolder/ + tag: v1 + """ + And a git-repository "SomeProjectWithLicenseLikeFolder.git" with the files + | path | + | LICENSE | + | licensecore/SomeUnrelatedFile.txt | + | SomeFolder/SomeFile.txt | + When I run "dfetch update" + Then 'MyProject' looks like: + """ + MyProject/ + SomeProjectWithLicenseLikeFolder/ + .dfetch_data.yaml + LICENSE + SomeFile.txt + dfetch.yaml + """ + Scenario: License is preserved in svn repo sparse checkout and cannot be ignored Given the manifest 'dfetch.yaml' in MyProject """ From eb5df298547549ba1fc17f3615ccd0ec50a5bad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 07:20:36 +0000 Subject: [PATCH 2/3] fix: drop root-level dirs that only match the license keep glob (#1428) The sparse-checkout keep patterns for license files (licen[cs]e*, copying*, copyright*) are written without a trailing slash, so in a git sparse-checkout pattern file they also match root-level directories, not just files. A repo with a root-level folder whose name starts with e.g. "licen" (such as "licensecore") got that whole folder vendored alongside the requested 'src:' subtree. After the sparse checkout materializes, drop any root-level directory that only matches because of the license glob, unless it is the directory the user actually asked for via 'src:'. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KcHRu7KByphASCkWkmfgYc --- CHANGELOG.rst | 1 + dfetch/vcs/git.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e7ceab6a2..f2000e640 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,7 @@ Release 0.15.0 (unreleased) ============================== * Add ``replay-patches`` command to step through patch contributions interactively (#1290) +* Fix a root-level directory matching a license glob being vendored alongside ``src:`` (#1428) Release 0.14.4 (released 2026-08-28) ==================================== diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index a2e42dce5..23d186570 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -12,6 +12,7 @@ from dfetch.log import get_logger from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline +from dfetch.util.license import is_license_file from dfetch.util.ssh import InvalidSshCommandError, sanitize_ssh_cmd from dfetch.util.util import in_directory, safe_rm from dfetch.vcs import git_submodule @@ -487,6 +488,29 @@ def _configure_sparse_checkout( f.write("\n".join(map(str, patterns)) + "\n") + @staticmethod + def _drop_directories_matching_license_globs(src: str | None) -> None: + """Undo the license keep patterns matching root-level directories. + + The sparse-checkout keep patterns for license files (see + ``LICENSE_GLOBS``) are written without a trailing slash so plain + ``fnmatch`` can also recognise them elsewhere; in a sparse-checkout + pattern file that same lack of a trailing slash makes them match + directories too, e.g. a root-level ``licensecore/`` folder ends up + vendored whole (#1428). Only files should ever be kept as license + files, so any root-level directory that merely shares the name is + removed again here, unless it is the directory requested via + ``src:`` itself. + """ + keep_root = Path(src).parts[0] if src else None + for entry in Path(".").iterdir(): + if ( + entry.is_dir() + and entry.name != keep_root + and is_license_file(entry.name) + ): + safe_rm(entry, within=".") + def checkout_version( self, options: CheckoutOptions, @@ -516,6 +540,9 @@ def checkout_version( ) run_on_cmdline(logger, ["git", "reset", "--hard", "FETCH_HEAD"]) + if options.src or options.ignore: + self._drop_directories_matching_license_globs(options.src) + if options.eol is not None: self._renormalize_eol() From 33dd4f372e552f64e32c74b19bad46e799eee215 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 08:52:22 +0000 Subject: [PATCH 3/3] fix: match glob 'src:' root components when preserving license dirs CodeRabbit review on #1429: the license-directory cleanup compared the first path component of 'src:' to each root entry with a literal string equality, so a 'src:' whose leading component is itself a glob (e.g. "licen*/") no longer matched its own selected directory and got deleted as a false-positive license folder. Compare with fnmatch instead, and add a regression scenario for a globbed 'src:' root. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KcHRu7KByphASCkWkmfgYc --- dfetch/vcs/git.py | 6 ++++-- features/keep-license-in-project.feature | 26 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 23d186570..edd675435 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -1,6 +1,7 @@ """Git specific implementation.""" import contextlib +import fnmatch import functools import os import re @@ -500,14 +501,15 @@ def _drop_directories_matching_license_globs(src: str | None) -> None: vendored whole (#1428). Only files should ever be kept as license files, so any root-level directory that merely shares the name is removed again here, unless it is the directory requested via - ``src:`` itself. + ``src:`` itself -- whose leading path component may itself be a + glob (e.g. ``licen*/``), so it is matched with ``fnmatch`` too. """ keep_root = Path(src).parts[0] if src else None for entry in Path(".").iterdir(): if ( entry.is_dir() - and entry.name != keep_root and is_license_file(entry.name) + and not (keep_root and fnmatch.fnmatch(entry.name, keep_root)) ): safe_rm(entry, within=".") diff --git a/features/keep-license-in-project.feature b/features/keep-license-in-project.feature index 4a4ef131f..66ac3a0f1 100644 --- a/features/keep-license-in-project.feature +++ b/features/keep-license-in-project.feature @@ -63,6 +63,32 @@ Feature: Keep license in project dfetch.yaml """ + Scenario: A 'src:' glob whose root component looks like a license file is kept + Given the manifest 'dfetch.yaml' in MyProject + """ + manifest: + version: 0.0 + projects: + - name: SomeProjectWithLicenseLikeSrc + url: some-remote-server/SomeProjectWithLicenseLikeSrc.git + src: licen*/ + tag: v1 + """ + And a git-repository "SomeProjectWithLicenseLikeSrc.git" with the files + | path | + | LICENSE | + | licensecore/SomeFile.txt | + When I run "dfetch update" + Then 'MyProject' looks like: + """ + MyProject/ + SomeProjectWithLicenseLikeSrc/ + .dfetch_data.yaml + LICENSE + SomeFile.txt + dfetch.yaml + """ + Scenario: License is preserved in svn repo sparse checkout and cannot be ignored Given the manifest 'dfetch.yaml' in MyProject """