From edb995e5daa3a96ab659a43bf2c33e064900876d Mon Sep 17 00:00:00 2001 From: Cristian Ramirez <62491943+Str0k@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:45:54 -0500 Subject: [PATCH] Allow subtree traversal from filesystem roots Co-authored-by: Codex --- CHANGES.rst | 1 + CHANGES_1.in.rst | 1 + pathspec/util.py | 4 ++-- tests/test_01_util.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6ba4c74..f0649c3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,7 @@ New features: Bug fixes: +- Fixed subtree traversal from filesystem roots and equivalent Windows paths with different casing. - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. - `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions. - `Issue #129`_ / `Pull #132`_: Fix GitIgnoreSpec re-including files under an excluded directory diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index 67a0bba..878f37f 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -16,6 +16,7 @@ New features: Bug fixes: +- Fixed subtree traversal from filesystem roots and equivalent Windows paths with different casing. - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. - `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions. - `Issue #129`_ / `Pull #132`_: Fix GitIgnoreSpec re-including files under an excluded directory diff --git a/pathspec/util.py b/pathspec/util.py index 34488d4..41ac12a 100644 --- a/pathspec/util.py +++ b/pathspec/util.py @@ -209,9 +209,9 @@ def _get_sub_path_safe(root: str, sub_dir: StrPath) -> str: Returns the sub-directory path relative to the root directory (:class:`str`). """ sub_abs = os.path.normpath(os.path.join(root, sub_dir)) - if sub_abs == root: + if os.path.normcase(sub_abs) == os.path.normcase(root): return '' - elif sub_abs.startswith(root + os.sep): + elif os.path.commonpath((root, sub_abs)) == root: return os.path.relpath(sub_abs, root) else: raise ValueError(f"{sub_dir=!r} must be relative to {root=!r}.") diff --git a/tests/test_01_util.py b/tests/test_01_util.py index d4dca4c..fb87796 100644 --- a/tests/test_01_util.py +++ b/tests/test_01_util.py @@ -218,6 +218,39 @@ def test_01_files_2_files(self): 'Dir/Inner/f', ]))) + def test_subdir_from_filesystem_root(self): + """Traverse only the requested subtree when the root ends in a separator.""" + self.make_files(['a.txt']) + root = Path(self.temp_dir).anchor + expected = {os.path.relpath(os.path.join(self.temp_dir, 'a.txt'), root)} + for subdir in (self.temp_dir, os.path.relpath(self.temp_dir, root)): + with self.subTest(subdir=subdir): + self.assertEqual(set(iter_tree_files(root, subdir=subdir)), expected) + self.assertEqual( + get_paths_from_entries(iter_tree_entries(root, subdir=subdir)), expected) + + def test_subdir_same_root(self): + """Both an absolute root and a dot select the root itself.""" + self.make_files(['a.txt']) + for subdir in ('.', self.temp_dir): + with self.subTest(subdir=subdir): + self.assertEqual(set(iter_tree_files(self.temp_dir, subdir=subdir)), {'a.txt'}) + + def test_subdir_rejects_outside_root(self): + """Reject parents and sibling paths sharing the root's string prefix.""" + for subdir in ('..', os.path.join('..', 'other'), str(self.temp_dir) + '-other'): + for traverse in (iter_tree_files, iter_tree_entries): + with self.subTest(subdir=subdir, traverse=traverse): + with self.assertRaises(ValueError): + list(traverse(self.temp_dir, subdir=subdir)) + + @unittest.skipUnless(os.path.normcase('A') == os.path.normcase('a'), 'Case-sensitive paths') + def test_subdir_case_insensitive_root(self): + """Accept equivalent root paths with different casing on Windows.""" + self.make_files(['a.txt']) + root = str(self.temp_dir).swapcase() + self.assertEqual(set(iter_tree_files(root, subdir=self.temp_dir)), {'a.txt'}) + def test_02_link_1_check_symlink(self): """ Tests whether links can be created.