From 19a61cd413bacd0a0294c9357a6e28aa721dce68 Mon Sep 17 00:00:00 2001 From: Gaven Lan Ren Date: Wed, 28 Oct 2026 13:38:45 -0400 Subject: [PATCH] fix(tree): support slices and reverse iteration Python 3 sends slices to `Tree.__getitem__`, but its legacy slice implementation only exists in `__getslice__`. `tree[:2]` therefore raises `TypeError`. `reversed(tree)` also raises because it applies `reversed` to a generator. Handle slices through the existing cache-to-object conversion and reverse cached entries before lazy conversion. Add return-type overloads, public API documentation and regressions for ordering, stride, negative/empty ranges, zero steps, independent results and nested paths. Validation: all eight new cases fail on the original CLI implementation. Afterward, the complete tree and typing modules pass 110 tests on each CLI/Gix backend with Python 3.12.14, Git 2.52.0 and GixPython 0.3.0 on Linux. Mypy (40 source files), basedpyright and applicable repository pre-commit hooks pass. Full suites and other platforms were not run. Assisted-by: OpenAI Codex (model: unknown, autonomous) --- git/objects/tree.py | 16 ++++++++++++++-- test/test_tree.py | 36 ++++++++++++++++++++++++++++++++++++ test/test_typing.py | 6 ++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/git/objects/tree.py b/git/objects/tree.py index bbb48320b..a4c6f2209 100644 --- a/git/objects/tree.py +++ b/git/objects/tree.py @@ -34,6 +34,7 @@ Type, Union, cast, + overload, ) from git.types import PathLike @@ -175,6 +176,8 @@ class Tree(IndexObject, git_diff.Diffable, util.Traversable): * Access a specific blob using the ``tree["filename"]`` notation. * You may likewise access by index, like ``blob = tree[0]``. + * Slicing, like ``tree[:2]``, returns a list of objects. + * ``reversed(tree)`` iterates over objects in reverse order. """ type: Literal["tree"] = "tree" @@ -377,7 +380,16 @@ def __iter__(self) -> Iterator[IndexObjUnion]: def __len__(self) -> int: return len(self._cache) - def __getitem__(self, item: Union[str, int, slice]) -> IndexObjUnion: + @overload + def __getitem__(self, item: Union[str, int]) -> IndexObjUnion: ... + + @overload + def __getitem__(self, item: slice) -> List[IndexObjUnion]: ... + + def __getitem__(self, item: Union[str, int, slice]) -> Union[IndexObjUnion, List[IndexObjUnion]]: + if isinstance(item, slice): + return list(self._iter_convert_to_object(self._cache[item])) + if isinstance(item, int): info = self._cache[item] return self._map_id_to_type[info[1] >> 12](self.repo, info[0], info[1], join_path(self.path, info[2])) @@ -409,7 +421,7 @@ def __contains__(self, item: Union[IndexObjUnion, PathLike]) -> bool: return False def __reversed__(self) -> Iterator[IndexObjUnion]: - return reversed(self._iter_convert_to_object(self._cache)) # type: ignore[call-overload] + return self._iter_convert_to_object(reversed(self._cache)) def _serialize(self, stream: "BytesIO") -> "Tree": """Ask Git to build this cache as a tree, then copy its raw object contents.""" diff --git a/test/test_tree.py b/test/test_tree.py index f5273aa26..eea3f574f 100644 --- a/test/test_tree.py +++ b/test/test_tree.py @@ -64,6 +64,42 @@ def test_tree_modifier_orders_non_utf8_names_as_git_does(): assert [safe_encode(item.name) for item in tree] == expected +@pytest.mark.parametrize( + "selection", + [slice(None), slice(1, 4), slice(None, None, 2), slice(-3, None), slice(None, None, -1), slice(99, None)], +) +def test_tree_slicing_follows_list_order_without_changing_the_tree(selection): + with fixture_repo("tree-order.sh") as repo: + tree = repo.head.commit.tree + original = list(tree) + selected = tree[selection] + assert isinstance(selected, list) + + def describe(items): + return [(type(item), item.path, item.mode, item.hexsha) for item in items] + + assert describe(selected) == describe(original[selection]) + selected.clear() + assert describe(list(tree)) == describe(original) + + +def test_tree_slicing_rejects_a_zero_step(): + with fixture_repo("tree-order.sh") as repo: + with pytest.raises(ValueError, match="slice step cannot be zero"): + repo.head.commit.tree[::0] + + +def test_reversed_tree_preserves_order_and_nested_paths(): + with fixture_repo("tree-order.sh") as repo: + tree = repo.head.commit.tree + forward = list(tree) + backward = list(reversed(tree)) + assert [(type(item), item.path, item.mode, item.hexsha) for item in backward] == [ + (type(item), item.path, item.mode, item.hexsha) for item in reversed(forward) + ] + assert [item.path for item in reversed(tree["file"])] == ["file/a"] + + @ddt.ddt class TestTree(TestBase): @with_rw_repo("HEAD") diff --git a/test/test_typing.py b/test/test_typing.py index ab810e110..b0dff8f06 100644 --- a/test/test_typing.py +++ b/test/test_typing.py @@ -79,6 +79,7 @@ def test_execute_output_types() -> None: from git import Remote, Repo from git.repo.base import BlameEntry from git.objects import Commit, Submodule + from git.objects.base import IndexObjUnion repo = Repo() remote = Remote(repo, "origin") @@ -86,6 +87,11 @@ def test_execute_output_types() -> None: removed_remotes: List[Remote] = [Remote.remove(repo, remote), Remote.rm(repo, remote), repo.delete_remote(remote)] blame = BlameEntry(repo.head.commit, range(1), "file", range(1)) commit: Commit = blame.commit + tree = commit.tree + indexed_tree_item: IndexObjUnion = tree[0] + named_tree_item: IndexObjUnion = tree["file"] + sliced_tree_items: List[IndexObjUnion] = tree[:2] + reversed_tree_items: List[IndexObjUnion] = list(reversed(tree)) submodule_entry: IndexEntry = IndexEntry.from_blob(Submodule(repo, b"\0" * 20)) Git().get_object_header(b"HEAD") Git().get_object_data(b"HEAD")