From d03d3d777bced1a0478dedef2a00fa6f6982892d Mon Sep 17 00:00:00 2001 From: Keerthana KT Date: Mon, 2 Nov 2026 17:46:57 +0530 Subject: [PATCH 1/2] fix: read reference arguments by exact name in revision APIs `Reference.__str__` returns the short name, so `refs/heads/main` becomes `main` and `refs/remotes/origin/main` becomes `origin/main`. The revision APIs turned reference objects into revisions with `str(ref)` or `Git._check_operand(ref, "revision")`, and Git's revision lookup (gitrevisions(7)) tries `refs/tags/` before `refs/heads/` and `refs/remotes/`, only warning about the ambiguity on stderr. A tag that shares a branch's short name therefore took the branch's place. Whoever controls a remote can supply such tags: `git fetch` auto-follows tags that point into fetched history, and a clone brings in every tag. With a branch `main` and fetched tags `main` and `origin/main` on another commit, `repo.heads.main.commit` still reported the branch, but these APIs used the tag's commit on both backends: - `Repo.commit()`, `Repo.tree()`, `Object.new()`, `Repo.iter_commits()` - `Repo.merge_base()`, `Repo.is_ancestor()`, `Diffable.diff()` - `Repo.blame()`, `Repo.blame_incremental()`, `Repo.archive()` - `IndexFile.from_tree()`, `IndexFile.merge_tree()`, `IndexFile.reset()` - `HEAD.reset()`, `Repo.create_head()`, `Repo.create_tag()` In the documented merge workflow, `merge_base(new_branch, original_branch)` followed by `index.merge_tree(original_branch, base=...)`, the merge commit received the tag's tree while its parents named the branch commits. `repo.head.reset(repo.heads.main, working_tree=True)` and `repo.index.reset(ref, head=True)` moved the current branch to the tag's commit and wrote its files into the working tree. Add `Git._check_revision()`. For a `SymbolicReference` it reads the exact reference through `dereference_recursive()`, the `show-ref`/Gix read behind `ref.object`, and passes on the object ID, which is how `SymbolicReference.set_object()` already resolves reference arguments. Strings keep Git's name guessing, as the changelog already documents. `merge_base()` resolves its top-level arguments before `_unpack_args()` flattens them. A reference that does not exist now raises `ValueError`, as `ref.object` does, instead of resolving to whatever Git guesses for its short name. On the CLI backend each reference argument costs one or two extra short Git calls; the Gix backend reads references natively, and `doc/gix-backend.md` is unchanged. Validation: the new `shadowed` case in `tests/fixtures/exact-refs.sh` backs `test_reference_arguments_do_not_resolve_same_named_tags` in `test/test_refs.py`, which checks the APIs above for a branch and a remote-tracking branch. It fails before the change and passes after it with `GIT_PYTHON_BACKEND=cli` and `GIT_PYTHON_BACKEND=gix` for SHA-1/SHA-256 and files/reftable (Git 2.52.0, GixPython 0.3.0, Python 3.11, macOS). Full suites with the change: CLI 1,915 passed and Gix 2,018 passed. Each run also had 116 failures, and the same 116 tests fail on the unpatched tree; they need history and tags such as `0.1.5`, which this shallow clone lacks. `ruff check`, `ruff format --check`, ShellCheck, `mypy --python-version=3.11` and `basedpyright --warnings` pass. Agent-Model: claude-opus-5-5 --- doc/source/changes.rst | 7 ++++++- git/cmd.py | 15 +++++++++++++++ git/diff.py | 6 ++++-- git/index/base.py | 3 +-- git/objects/base.py | 3 ++- git/objects/commit.py | 4 ++-- git/refs/head.py | 2 +- git/refs/symbolic.py | 2 +- git/refs/tag.py | 2 +- git/repo/base.py | 18 ++++++++++-------- test/test_refs.py | 37 ++++++++++++++++++++++++++++++++++++ tests/fixtures/exact-refs.sh | 11 +++++++++++ 12 files changed, 91 insertions(+), 19 deletions(-) diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 5962ccf56..e8cdf8090 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -57,7 +57,12 @@ API changes * Reference reads require the exact name. A missing branch no longer resolves to a similarly named tag, so an unborn branch's initial commit has no parent. A tag named ``REBASE_HEAD`` no longer reports a rebase in progress. Revision - APIs such as ``Repo.commit(rev)`` retain Git's name guessing. + APIs such as ``Repo.commit(rev)`` retain Git's name guessing for strings. + A reference object passed as a revision, for example to ``Repo.commit()``, + ``Repo.iter_commits()``, ``Repo.merge_base()``, ``IndexFile.from_tree()`` or + ``HEAD.reset()``, is read by its exact name, so a tag named like a branch or + remote-tracking branch no longer takes its place. A missing reference raises + ``ValueError``, as its ``object`` property does. * Packed tag names containing non-UTF-8 bytes are represented losslessly as surrogate-escaped strings. ``safe_encode()`` recovers their original bytes, and Gix reference validation and resolution receive those bytes directly. diff --git a/git/cmd.py b/git/cmd.py index ebef997ca..a2fe405bf 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1023,6 +1023,21 @@ def _check_operand(value: Any, label: str = "operand") -> str: raise UnsafeOptionError(f"Invalid {label}: {value!r}") return value + @staticmethod + def _check_revision(value: Any, label: str = "revision") -> str: + """Validate a revision, passing a reference object as the object ID it holds. + + A reference converts to its short name, for which Git's revision lookup tries + other references, such as a same-named tag, before ``refs/heads/`` and + ``refs/remotes/``. Read the exact reference instead, as + ``SymbolicReference.object`` does. + """ + from git.refs.symbolic import SymbolicReference + + if isinstance(value, SymbolicReference): + value = value.dereference_recursive(value.repo, value.path) + return Git._check_operand(value, label) + def _require_version(self) -> None: key = None if self._version_info_token is not self._refresh_token: diff --git a/git/diff.py b/git/diff.py index 0118c3662..969ee027e 100644 --- a/git/diff.py +++ b/git/diff.py @@ -261,8 +261,10 @@ def diff( clusterable_short_options="46abceflmnpqrstuvwzBCDMNRW", ) - if isinstance(other, str): - other = self.repo.rev_parse(Git._check_operand(other, "revision")).hexsha + from git.refs.symbolic import SymbolicReference + + if isinstance(other, (str, SymbolicReference)): + other = self.repo.rev_parse(Git._check_revision(other)).hexsha native = _backend.tree_diff(self.repo, self, other, paths, create_patch, kwargs) if native is not NotImplemented: return native diff --git a/git/index/base.py b/git/index/base.py index ce7417546..93300b71c 100644 --- a/git/index/base.py +++ b/git/index/base.py @@ -280,8 +280,7 @@ def _tree_oid(self, tree: Treeish) -> str: tree = tree[0] if isinstance(tree, bytes): tree = tree.hex() if len(tree) == self.repo._oid_size else tree.decode("ascii") - value = str(tree) - Git._check_operand(value, "tree revision") + value = Git._check_revision(tree, "tree revision") return self.repo.git._call_process_safe("rev_parse", "--verify", "--end-of-options", value + "^{tree}") def _index_path(self) -> PathLike: diff --git a/git/objects/base.py b/git/objects/base.py index b157c0049..bac490851 100644 --- a/git/objects/base.py +++ b/git/objects/base.py @@ -9,6 +9,7 @@ import gitdb.typ as dbtyp +from git.cmd import Git from git.compat import force_text from git.exc import WorkTreeRepositoryUnsupported from git.util import LazyMixin, bin_to_hex, join_path_native, stream_copy @@ -138,7 +139,7 @@ def new(cls, repo: "Repo", id: Union[str, "Reference"]) -> AnyGitObject: This cannot be a ``__new__`` method as it would always call :meth:`__init__` with the input id which is not necessarily a binsha. """ - return repo.rev_parse(str(id)) + return repo.rev_parse(Git._check_revision(id)) @classmethod def new_from_sha(cls, repo: "Repo", sha1: bytes) -> AnyGitObject: diff --git a/git/objects/commit.py b/git/objects/commit.py index dfc51b2f3..185b08728 100644 --- a/git/objects/commit.py +++ b/git/objects/commit.py @@ -465,8 +465,8 @@ def iter_items( args_list.extend(paths_tup) # END if paths - Git._check_operand(str(rev), "revision") - native = _backend.history(repo.git, str(rev), paths, kwargs) + rev = Git._check_revision(rev) + native = _backend.history(repo.git, rev, paths, kwargs) if native is not NotImplemented: return (cls(repo, hex_to_bin(oid)) for oid in native) proc = repo.git._call_process_safe("rev_list", rev, args_list, as_process=True, **kwargs) diff --git a/git/refs/head.py b/git/refs/head.py index 4f348080d..a49e4a9b8 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -121,7 +121,7 @@ def reset( # END working tree handling try: - oid = self.repo.rev_parse(Git._check_operand(commit, "revision")).hexsha + oid = self.repo.rev_parse(Git._check_revision(commit)).hexsha self.repo.git._call_process_safe("reset", mode, oid, "--", paths, **kwargs) except GitCommandError as e: # git nowadays may use 1 as status to indicate there are still unstaged diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 3924fb2ca..09a1955e1 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -575,7 +575,7 @@ def _create( ) -> T_References: full_path = cls.to_full_path(path) cls._get_validated_ref_path(repo, full_path) - target = repo.rev_parse(str(reference)) if resolve else reference + target = repo.rev_parse(Git._check_revision(reference)) if resolve else reference ref = cls(repo, full_path) if force: ref.set_reference(target, logmsg) diff --git a/git/refs/tag.py b/git/refs/tag.py index 66fa54374..72ec9be5d 100644 --- a/git/refs/tag.py +++ b/git/refs/tag.py @@ -170,7 +170,7 @@ def create( name = Git._check_operand(path, "tag") cls._get_validated_ref_path(repo, cls.to_full_path(name)) - reference = Git._check_operand(reference, "revision") + reference = Git._check_revision(reference) oid = repo.rev_parse(reference).hexsha options = [] if allow_unsafe_options else ["--no-sign"] repo.git._call_process_safe("tag", *options, "--", name, oid, **kwargs) diff --git a/git/repo/base.py b/git/repo/base.py index 4b0f00eac..01f75c113 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -909,7 +909,7 @@ def commit(self, rev: Union[str, Commit_ish, None] = None) -> Commit: """ if rev is None: return self.head.commit - return to_commit(self.rev_parse(str(rev))) + return to_commit(self.rev_parse(Git._check_revision(rev))) def iter_trees(self, *args: Any, **kwargs: Any) -> Iterator["Tree"]: """:return: Iterator yielding :class:`~git.objects.tree.Tree` objects @@ -939,7 +939,7 @@ def tree(self, rev: Union[Tree_ish, str, None] = None) -> "Tree": """ if rev is None: return self.head.commit.tree - obj = self.rev_parse(str(rev)) + obj = self.rev_parse(Git._check_revision(rev)) if obj.type == "tree": obj.path = getattr(obj, "path", "") return obj @@ -1029,10 +1029,12 @@ def merge_base(self, *rev: TBD, allow_unsafe_options: bool = False, **kwargs: An options=Git._option_candidates(rev, kwargs), unsafe_options=self.unsafe_git_revision_options ) + # Resolve reference objects before flattening turns them into short names. + revs = [item if isinstance(item, (list, tuple)) else Git._check_revision(item) for item in rev] res: List[Commit] = [] try: lines: List[str] = self.git._call_process_safe( - "merge_base", "--", *(Git._check_operand(item, "revision") for item in Git._unpack_args(rev)), **kwargs + "merge_base", "--", *(Git._check_operand(item, "revision") for item in Git._unpack_args(revs)), **kwargs ).splitlines() except GitCommandError as err: if err.status != 1: @@ -1065,8 +1067,8 @@ def is_ancestor(self, ancestor_rev: Commit, rev: Commit) -> bool: "merge_base", "--is-ancestor", "--", - Git._check_operand(ancestor_rev, "revision"), - Git._check_operand(rev, "revision"), + Git._check_revision(ancestor_rev), + Git._check_revision(rev), ) except GitCommandError as err: if err.status == 1: @@ -1303,7 +1305,7 @@ def blame_incremental( data: bytes = self.git._call_process_safe( "blame", - Git._check_operand(rev, "revision") if rev is not None else None, + Git._check_revision(rev) if rev is not None else None, "--no-textconv" if not allow_unsafe_options else None, "--", file, @@ -1424,7 +1426,7 @@ def blame( ) data: bytes = self.git._call_process_safe( "blame", - Git._check_operand(rev, "revision") if rev is not None else None, + Git._check_revision(rev) if rev is not None else None, *rev_opts_list, "--no-textconv" if not allow_unsafe_options else None, "--", @@ -1925,7 +1927,7 @@ def archive( self.git._call_process_safe( "archive", "--", - Git._check_operand(treeish, "revision"), + Git._check_revision(treeish), *path, _allow_network=bool(kwargs.get("remote")), _config=() diff --git a/test/test_refs.py b/test/test_refs.py index 69e04fff1..b5d1b5cd6 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -4,8 +4,10 @@ # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ import contextlib +import io import os import os.path as osp +import tarfile import unittest from itertools import chain from pathlib import Path @@ -22,6 +24,8 @@ GitConfigParser, Git, Head, + IndexFile, + Object, Reference, RefLog, Remote, @@ -222,6 +226,39 @@ def test_initial_commit_does_not_inherit_lookalike_tag_history(object_format, re assert repo.git.rev_list("--count", "HEAD") == "1" +@pytest.mark.parametrize("object_format", ["sha1", "sha256"]) +@pytest.mark.parametrize("ref_format", ["files", "reftable"]) +def test_reference_arguments_do_not_resolve_same_named_tags(object_format, ref_format): + with fixture_repo("exact-refs.sh", object_format, ref_format, "shadowed", write=True) as repo: + local = repo.heads.main.commit + foreign = repo.tags.main.commit + assert repo.tags["origin/main"].commit == foreign != local + # Git's name guessing prefers the tags, as for any short name. + assert repo.commit("main") == repo.commit("origin/main") == foreign + branches = (repo.heads.main, RemoteReference(repo, "refs/remotes/origin/main")) + for index, ref in enumerate(branches): + assert repo.commit(ref) == Object.new(repo, ref) == local + assert repo.tree(ref) == local.tree + assert list(repo.iter_commits(ref)) == [local] + assert repo.merge_base(ref, local) == [local] + assert not repo.is_ancestor(foreign, ref) + assert local.diff(ref) == [] + assert [commit for commit, _ in repo.blame(ref, "file")] == [local] + assert [entry.commit for entry in repo.blame_incremental(ref, "file")] == [local] + stream = io.BytesIO() + repo.archive(stream, ref) + with tarfile.open(fileobj=io.BytesIO(stream.getvalue())) as archive: + assert archive.getnames() == ["file"] + assert IndexFile.from_tree(repo, ref).write_tree() == local.tree + assert repo.create_head(f"copy-{index}", ref).commit == local + assert repo.create_tag(f"copy-{index}", ref=ref).commit == local + repo.head.reset(ref, index=True) + repo.index.reset(ref, head=True) + repo.index.merge_tree(ref) + assert repo.head.commit == local + assert repo.index.write_tree() == local.tree + + @pytest.mark.parametrize("object_format", ["sha1", "sha256"]) @pytest.mark.parametrize("ref_format", ["files", "reftable"]) def test_rebase_detection_ignores_tag_named_rebase_head(object_format, ref_format): diff --git a/tests/fixtures/exact-refs.sh b/tests/fixtures/exact-refs.sh index 71419c138..10317556f 100644 --- a/tests/fixtures/exact-refs.sh +++ b/tests/fixtures/exact-refs.sh @@ -15,6 +15,17 @@ git -C repo fetch ../source 'refs/tags/*:refs/tags/*' if [ "${3-}" = symbolic ]; then git -C repo symbolic-ref refs/tags/REBASE_HEAD refs/tags/refs/heads/unborn fi +if [ "${3-}" = shadowed ]; then + # Fetched tags share the short names of a local branch and a remote-tracking branch. + git -C repo fetch ../source 'refs/heads/main:refs/tags/main' 'refs/heads/main:refs/tags/origin/main' + blob=$(printf 'local\n' | git -C repo hash-object -w --stdin) + tree=$(printf '100644 blob %s\tfile\n' "$blob" | git -C repo mktree) + commit=$(git -C repo commit-tree -m 'Local history' "$tree") + for path in refs/heads/main refs/remotes/origin/main; do + git -C repo update-ref "$path" "$commit" + done + git -C repo symbolic-ref HEAD refs/heads/main +fi if [ "${3-}" = existing ]; then commit=$(git -C source rev-parse HEAD) for path in refs/heads/unborn refs/stash ORIG_HEAD REBASE_HEAD; do From 6de90f1c13c5dbc5e4e51f2d6c9c716e66dbeebc Mon Sep 17 00:00:00 2001 From: Byron Date: Wed, 4 Nov 2026 06:51:04 +0100 Subject: [PATCH 2/2] fix(refs): preserve full reference names in revision APIs Converting reference arguments to object IDs prevents same-named tags from replacing branches, but loses the reference identity required by several Git operations. Reflog walks then return no commits, fork-point lookup fails with `No such ref`, and the default `upload-archive` server rejects the literal ID. References inside nested `merge_base()` arguments also still became short names and could select a shadowing tag. Keep the exact-reference existence and symbolic-chain checks in `Git._check_revision()`, then pass the validated full ref name. This preserves Git's ref-dependent behavior without operation-specific conversion switches. Strings retain Git's revision interpretation and commit objects retain their IDs. Normalize `os.PathLike` operands through `os.fsdecode()` before validation so their filesystem paths, including byte paths, are used instead of their display strings. Flatten nested list/tuple merge-base arguments while preserving reference objects until each operand is validated. Extend `tests/fixtures/exact-refs.sh` with an upstream advance and rewind plus a topic based on its old tip. Regressions compare branch, remote-tracking and HEAD behavior with Git 2.54.0 across SHA-1/SHA-256 and files/reftable repositories. They cover shadowing tags, missing refs, nested operands, option spellings, remote archives and safe/unsafe `PathLike` values. Update the changelog and document the retained CLI fallbacks: GixPython 0.3.0 exposes neither the required reflog-walk/fork-point operations nor an upload-archive client. Assisted-by: GPT 6.1 Sol Co-authored-by: GPT 6.1 Sol --- doc/gix-backend.md | 21 +++++++- doc/source/changes.rst | 8 +-- git/cmd.py | 11 +++-- git/repo/base.py | 12 +++-- test/test_refs.py | 96 ++++++++++++++++++++++++++++++++++++ tests/fixtures/exact-refs.sh | 12 ++++- 6 files changed, 148 insertions(+), 12 deletions(-) diff --git a/doc/gix-backend.md b/doc/gix-backend.md index 2d9834987..c2959c5c3 100644 --- a/doc/gix-backend.md +++ b/doc/gix-backend.md @@ -374,7 +374,7 @@ and predate the current fallback; they do not establish native coverage. | Worktree inventory, `worktree list` | Main and linked worktree metadata, including bare main repositories and locks | Prunable entries; GIX-14 | | `Repo.merge_base`, `Repo.is_ancestor` | Native graph queries for two revisions | Octopus/fork-point and other options | | `Commit.count` | Reachable commit count, skip/limit/first-parent | Path filters and other revision options | -| `Commit.iter_items`, `Repo.iter_commits` | First-parent walks and a single tip | General history ordering; GIX-8 | +| `Commit.iter_items`, `Repo.iter_commits` | First-parent walks and a single tip | Reflog walks, general history ordering; GIX-8 | | Index entry reads, `ls_files` | Stage/mode/OID/path and exposed index flags | Custom and sparse indexes; GIX-3/4 | | `IndexFile.version`, `update_index` query | Native index version | Actual `update-index` mutations | | Index editing and `IndexFile.write` | Opaque byte snapshots; atomic locked publication | Private-file edits always use Git: arbitrary native index loading and strict pathname validation are missing; GIX-3/4/13/26 | @@ -392,6 +392,25 @@ and predate the current fallback; they do not establish native coverage. | `Repo.untracked_files` | Native directory walk | Extra status options and non-root command directories | | `Repo.ignored` | Native excludes with tracked-file suppression | Symlink/submodule traversal, unsupported path normalization, and unavailable path metadata | +Revision APIs preserve a reference object's validated full name. The shared +`Git._check_revision()` first uses the exact reference reader to verify existence +and the symbolic-reference chain; it passes the full name rather than the resolved +object ID. Strings retain Git's name guessing, and commit objects retain their IDs. +This prevents same-named tags from replacing branches without losing the ref +identity needed by reflog walks, fork-point lookups and remote archives. +`merge_base()` validates nested list/tuple operands before string conversion. +`exact-refs.sh` covers these paths with shadowing tags and missing references in +SHA-1/SHA-256 and files/reftable repositories on both backends. + +These name-sensitive operations retain CLI fallback. GixPython 0.3.0's +`Repository.rev_walk()` has no Git-compatible reflog-walk mode, +`Repository.merge_base()` exposes graph merge bases rather than reflog-based +fork-point selection, and no upload-archive client API is exposed. The regressions +compare Git 2.54.0's reflog history, old fork point after an upstream rewind, +and default upload-archive server behavior, which rejects literal object IDs. +Native replacements must expose matching reflog walking and fork-point selection +and the upload-archive protocol; no Python implementation substitutes for them. + Reference reads require an exact name, unlike revision APIs that intentionally apply Git's name guessing. `reference_info()` uses `Repository.try_find_reference()` and verifies the returned name for `HEAD` and diff --git a/doc/source/changes.rst b/doc/source/changes.rst index e8cdf8090..487ac8916 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -60,9 +60,11 @@ API changes APIs such as ``Repo.commit(rev)`` retain Git's name guessing for strings. A reference object passed as a revision, for example to ``Repo.commit()``, ``Repo.iter_commits()``, ``Repo.merge_base()``, ``IndexFile.from_tree()`` or - ``HEAD.reset()``, is read by its exact name, so a tag named like a branch or - remote-tracking branch no longer takes its place. A missing reference raises - ``ValueError``, as its ``object`` property does. + ``HEAD.reset()``, is validated by its exact name and passed using its full ref + name, so a tag named like a branch or remote-tracking branch no longer takes + its place. The ref name also preserves reflog walks, fork-point lookups and + remote archives. A missing reference raises ``ValueError``, as its ``object`` + property does. * Packed tag names containing non-UTF-8 bytes are represented losslessly as surrogate-escaped strings. ``safe_encode()`` recovers their original bytes, and Gix reference validation and resolution receive those bytes directly. diff --git a/git/cmd.py b/git/cmd.py index a2fe405bf..991a494a3 100644 --- a/git/cmd.py +++ b/git/cmd.py @@ -1025,17 +1025,20 @@ def _check_operand(value: Any, label: str = "operand") -> str: @staticmethod def _check_revision(value: Any, label: str = "revision") -> str: - """Validate a revision, passing a reference object as the object ID it holds. + """Validate a revision, preserving a reference object's full name. A reference converts to its short name, for which Git's revision lookup tries other references, such as a same-named tag, before ``refs/heads/`` and - ``refs/remotes/``. Read the exact reference instead, as - ``SymbolicReference.object`` does. + ``refs/remotes/``. Verify the exact reference exists before passing its full + name, retaining its identity for reflog walks and remote archives. """ from git.refs.symbolic import SymbolicReference if isinstance(value, SymbolicReference): - value = value.dereference_recursive(value.repo, value.path) + value.dereference_recursive(value.repo, value.path) + value = value.path + if isinstance(value, os.PathLike): + value = os.fsdecode(value) return Git._check_operand(value, label) def _require_version(self) -> None: diff --git a/git/repo/base.py b/git/repo/base.py index 01f75c113..21eb2e118 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1029,12 +1029,18 @@ def merge_base(self, *rev: TBD, allow_unsafe_options: bool = False, **kwargs: An options=Git._option_candidates(rev, kwargs), unsafe_options=self.unsafe_git_revision_options ) - # Resolve reference objects before flattening turns them into short names. - revs = [item if isinstance(item, (list, tuple)) else Git._check_revision(item) for item in rev] + def checked_revisions(items: Sequence[Any]) -> Iterator[str]: + # Preserve reference objects until each flattened operand is validated. + for item in items: + if isinstance(item, (list, tuple)): + yield from checked_revisions(item) + else: + yield Git._check_revision(item) + res: List[Commit] = [] try: lines: List[str] = self.git._call_process_safe( - "merge_base", "--", *(Git._check_operand(item, "revision") for item in Git._unpack_args(revs)), **kwargs + "merge_base", "--", *checked_revisions(rev), **kwargs ).splitlines() except GitCommandError as err: if err.status != 1: diff --git a/test/test_refs.py b/test/test_refs.py index b5d1b5cd6..bd0febf82 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -259,6 +259,102 @@ def test_reference_arguments_do_not_resolve_same_named_tags(object_format, ref_f assert repo.index.write_tree() == local.tree +@pytest.mark.parametrize("object_format", ["sha1", "sha256"]) +@pytest.mark.parametrize("ref_format", ["files", "reftable"]) +def test_merge_base_preserves_nested_reference_arguments(object_format, ref_format): + with fixture_repo("exact-refs.sh", object_format, ref_format, "shadowed") as repo: + for ref in (repo.heads.main, RemoteReference(repo, "refs/remotes/origin/main")): + for left, right in (([ref], ref), (([ref],), ref), (ref, [ref]), ([ref.path], (ref.path,))): + assert repo.merge_base(left, right) == [ref.commit] + + +@pytest.mark.parametrize("object_format", ["sha1", "sha256"]) +@pytest.mark.parametrize("ref_format", ["files", "reftable"]) +@pytest.mark.parametrize("path_type", [Path, PathLikeMock]) +def test_revision_arguments_preserve_pathlike_paths(object_format, ref_format, path_type): + with fixture_repo("exact-refs.sh", object_format, ref_format, "shadowed") as repo: + local = repo.head.commit + for name in ("HEAD", "refs/heads/main", "refs/remotes/origin/main"): + path = path_type(name) + ref = SymbolicReference(repo, path) + assert repo.commit(ref) == ref.object == local + assert repo.merge_base(ref, local) == [local] + assert repo.merge_base(path, local) == [local] + assert repo.merge_base(([path],), [local]) == [local] + + +@pytest.mark.parametrize("revision", ["HEAD", "--all", "HEAD\0", "HEAD\r", "HEAD\n"]) +@pytest.mark.parametrize("as_bytes", [False, True]) +def test_check_revision_normalizes_pathlike_before_validation(revision, as_bytes): + path = Mock(spec=os.PathLike, __fspath__=Mock(return_value=os.fsencode(revision) if as_bytes else revision)) + if revision == "HEAD": + assert Git._check_revision(path) == revision + else: + with pytest.raises(UnsafeOptionError, match="Invalid revision"): + Git._check_revision(path) + + +@pytest.mark.parametrize("object_format", ["sha1", "sha256"]) +@pytest.mark.parametrize("ref_format", ["files", "reftable"]) +def test_revision_arguments_validate_missing_references(object_format, ref_format): + with fixture_repo("exact-refs.sh", object_format, ref_format) as repo: + for path in ("refs/heads/unborn", "REBASE_HEAD"): + ref = SymbolicReference(repo, path) + foreign = repo.tags[path].commit + assert repo.git.rev_parse("--verify", path) == foreign.hexsha + with pytest.raises(ValueError, match="does not exist or is invalid"): + repo.commit(ref) + with pytest.raises(ValueError, match="does not exist or is invalid"): + repo.merge_base([ref], foreign) + + +@pytest.mark.parametrize("object_format", ["sha1", "sha256"]) +@pytest.mark.parametrize("ref_format", ["files", "reftable"]) +def test_reflog_walk_preserves_reference_names(object_format, ref_format): + with fixture_repo("exact-refs.sh", object_format, ref_format, "reflog") as repo: + for ref in (repo.heads.main, RemoteReference(repo, "refs/remotes/origin/main"), repo.head): + expected = repo.git.rev_list("--walk-reflogs", ref.path, "--").splitlines() + assert len(expected) >= 2 + for options in ({"walk_reflogs": True}, {"walk-reflogs": True}): + assert [commit.hexsha for commit in repo.iter_commits(ref, **options)] == expected + assert [commit.hexsha for commit in Commit.iter_items(repo, ref, max_count=1, **options)] == expected[ + :1 + ] + with pytest.raises(ValueError, match="does not exist or is invalid"): + list(repo.iter_commits(SymbolicReference(repo, "REBASE_HEAD"), walk_reflogs=True)) + + +@pytest.mark.parametrize("object_format", ["sha1", "sha256"]) +@pytest.mark.parametrize("ref_format", ["files", "reftable"]) +def test_fork_point_preserves_upstream_reference_name(object_format, ref_format): + with fixture_repo("exact-refs.sh", object_format, ref_format, "reflog") as repo: + topic = repo.heads.topic + old_tip = topic.commit.parents[0] + for upstream in (repo.heads.main, RemoteReference(repo, "refs/remotes/origin/main")): + assert repo.merge_base(upstream, topic) == [upstream.commit] + assert repo.git.merge_base("--fork-point", upstream.path, topic.path) == old_tip.hexsha + for options in ({"fork_point": True}, {"fork_p": True}): + assert repo.merge_base(upstream, topic, **options) == [old_tip] + with pytest.raises(ValueError, match="does not exist or is invalid"): + repo.merge_base(SymbolicReference(repo, "REBASE_HEAD"), topic, fork_point=True) + + +@pytest.mark.parametrize("object_format", ["sha1", "sha256"]) +@pytest.mark.parametrize("ref_format", ["files", "reftable"]) +def test_remote_archive_preserves_reference_name(object_format, ref_format): + with fixture_repo("exact-refs.sh", object_format, ref_format, "shadowed") as repo: + for ref in (repo.heads.main, RemoteReference(repo, "refs/remotes/origin/main"), repo.head): + expected = io.BytesIO() + repo.git.archive("--remote=" + repo.git_dir, ref.path, output_stream=expected) + stream = io.BytesIO() + repo.archive(stream, ref, remote=repo.git_dir) + assert stream.getvalue() == expected.getvalue() + with tarfile.open(fileobj=io.BytesIO(stream.getvalue())) as archive: + assert archive.getnames() == ["file"] + with pytest.raises(ValueError, match="does not exist or is invalid"): + repo.archive(io.BytesIO(), SymbolicReference(repo, "REBASE_HEAD"), remote=repo.git_dir) + + @pytest.mark.parametrize("object_format", ["sha1", "sha256"]) @pytest.mark.parametrize("ref_format", ["files", "reftable"]) def test_rebase_detection_ignores_tag_named_rebase_head(object_format, ref_format): diff --git a/tests/fixtures/exact-refs.sh b/tests/fixtures/exact-refs.sh index 10317556f..845b82b75 100644 --- a/tests/fixtures/exact-refs.sh +++ b/tests/fixtures/exact-refs.sh @@ -15,7 +15,7 @@ git -C repo fetch ../source 'refs/tags/*:refs/tags/*' if [ "${3-}" = symbolic ]; then git -C repo symbolic-ref refs/tags/REBASE_HEAD refs/tags/refs/heads/unborn fi -if [ "${3-}" = shadowed ]; then +if [ "${3-}" = shadowed ] || [ "${3-}" = reflog ]; then # Fetched tags share the short names of a local branch and a remote-tracking branch. git -C repo fetch ../source 'refs/heads/main:refs/tags/main' 'refs/heads/main:refs/tags/origin/main' blob=$(printf 'local\n' | git -C repo hash-object -w --stdin) @@ -25,6 +25,16 @@ if [ "${3-}" = shadowed ]; then git -C repo update-ref "$path" "$commit" done git -C repo symbolic-ref HEAD refs/heads/main + if [ "${3-}" = reflog ]; then + old_tip=$(git -C repo commit-tree -p "$commit" -m 'Old tip' "$tree") + topic=$(git -C repo commit-tree -p "$old_tip" -m 'Topic history' "$tree") + git -C repo update-ref refs/heads/topic "$topic" + git -C repo tag topic refs/tags/main + for path in refs/heads/main refs/remotes/origin/main; do + git -C repo update-ref --create-reflog -m 'Advance upstream' "$path" "$old_tip" + git -C repo update-ref -m 'Rewind upstream' "$path" "$commit" + done + fi fi if [ "${3-}" = existing ]; then commit=$(git -C source rev-parse HEAD)