Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion doc/gix-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion doc/source/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ 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 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.
Expand Down
18 changes: 18 additions & 0 deletions git/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,24 @@ 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, 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/``. 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.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:
key = None
if self._version_info_token is not self._refresh_token:
Expand Down
6 changes: 4 additions & 2 deletions git/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions git/index/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion git/objects/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions git/objects/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion git/refs/head.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion git/refs/symbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion git/refs/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 16 additions & 8 deletions git/repo/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1029,10 +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
)

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(rev)), **kwargs
"merge_base", "--", *checked_revisions(rev), **kwargs
).splitlines()
except GitCommandError as err:
if err.status != 1:
Expand Down Expand Up @@ -1065,8 +1073,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:
Expand Down Expand Up @@ -1303,7 +1311,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,
Expand Down Expand Up @@ -1424,7 +1432,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,
"--",
Expand Down Expand Up @@ -1925,7 +1933,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=()
Expand Down
Loading
Loading