From 6bcb25b80071cfc59c72f2bbb03318a87f769a39 Mon Sep 17 00:00:00 2001 From: Byron Date: Wed, 9 Sep 2026 18:43:25 +0200 Subject: [PATCH] Clarify detached HEAD access and attach writable test fixtures (#2230) Reading head.reference or active_branch raises TypeError when HEAD points directly to a commit, but the public documentation did not clearly explain how to access that commit. Document head.commit.hexsha for attached and detached HEADs, explain the reference setter/getter asymmetry, and clarify that active_branch requires an attached HEAD. Preserve the exception type and existing message prefix while adding a hint to use .commit or .object. Writable test fixtures assumed cloning produced an attached HEAD, so their branch access could fail when the source checkout was detached. Have with_rw_repo create and attach master at the requested revision when its clone is detached, retaining the clone's branch and tracking configuration otherwise. Explicitly attach the temporary bare remote to its own master branch before cloning it for remote tests. Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 --- doc/source/tutorial.rst | 8 +++++++- git/refs/symbolic.py | 37 +++++++++++++++++++++++++++++++------ git/repo/base.py | 6 +++++- test/lib/helper.py | 11 +++++++++-- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/doc/source/tutorial.rst b/doc/source/tutorial.rst index d095d3be3..1edfa4e11 100644 --- a/doc/source/tutorial.rst +++ b/doc/source/tutorial.rst @@ -78,6 +78,12 @@ Query relevant repository paths ... :class:`Heads ` Heads are branches in git-speak. :class:`References ` are pointers to a specific commit or to other references. Heads and :class:`Tags ` are a kind of references. GitPython allows you to query them rather intuitively. +To obtain the current commit ID, use ``repo.head.commit.hexsha``. This works both +on a branch and with a detached HEAD, provided HEAD resolves to an existing commit. +When ``repo.head.is_detached`` is true, HEAD points directly to a commit and there +is no active branch: reading ``repo.head.reference`` or ``repo.active_branch`` +raises :exc:`TypeError`. The branch examples below assume an attached HEAD. + .. literalinclude:: ../../test/test_docs.py :language: python :dedent: 8 @@ -152,7 +158,7 @@ Examining References :start-after: # [2-test_references_and_objects] :end-before: # ![2-test_references_and_objects] -A :class:`symbolic reference ` is a special case of a reference as it points to another reference instead of a commit. +A :class:`symbolic reference ` can point to another reference. When detached, it points directly to a commit instead. Reading its ``commit`` property resolves the commit in either state. Assigning a commit to ``reference`` detaches it; reading ``reference`` then raises :exc:`TypeError`. .. literalinclude:: ../../test/test_docs.py :language: python diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py index 824d0c46c..7c3eac508 100644 --- a/git/refs/symbolic.py +++ b/git/refs/symbolic.py @@ -59,12 +59,14 @@ def _git_dir(repo: "Repo", path: Union[PathLike, None]) -> PathLike: class SymbolicReference: - """Special case of a reference that is symbolic. + """A reference that can point to another reference or be detached. - This does not point to a specific commit, but to another - :class:`~git.refs.head.Head`, which itself specifies a commit. + An attached :class:`~git.refs.head.HEAD` usually points to a + :class:`~git.refs.head.Head`, which itself specifies a commit. A detached + :class:`~git.refs.head.HEAD` points directly to a commit instead. - A typical example for a symbolic reference is :class:`~git.refs.head.HEAD`. + Use :attr:`commit` to access the commit in either case, and :attr:`reference` + to access the target reference when attached. """ __slots__ = ("repo", "path") @@ -416,7 +418,15 @@ def set_object( @property def commit(self) -> "Commit": - """Query or set commits directly""" + """The commit this reference resolves to, whether detached or symbolic. + + For example, ``repo.head.commit.hexsha`` returns the current commit ID + both on a branch and with a detached HEAD. HEAD must resolve to an + existing commit; an unborn branch in an empty repository has none. + + Assigning updates the commit without changing whether this reference + is detached. + """ return self._get_commit() @commit.setter @@ -443,7 +453,10 @@ def _get_reference(self) -> "Reference": """ sha, target_ref_path = self._get_ref_info(self.repo, self.path) if target_ref_path is None: - raise TypeError("%s is a detached symbolic reference as it points to %r" % (self, sha)) + raise TypeError( + "%s is a detached symbolic reference as it points to %r. " + "Use .commit or .object to access the target directly." % (self, sha) + ) return cast("Reference", self.from_path(self.repo, target_ref_path)) def set_reference( @@ -531,6 +544,18 @@ def set_reference( # Aliased reference @property def reference(self) -> "Reference": + """The reference we point to, available only when not detached. + + Check :attr:`is_detached` before reading this property if a target + reference is required. To access the target commit or object in either + state, use :attr:`commit` or :attr:`object` instead. + + Assigning a reference keeps this reference symbolic. Assigning a git + object or revision string detaches it; reading this property then raises. + + :raise TypeError: + If this reference is detached when reading the property. + """ return self._get_reference() @reference.setter diff --git a/git/repo/base.py b/git/repo/base.py index 7039d0320..f326266d6 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -1150,7 +1150,11 @@ def ignored(self, *paths: PathLike) -> List[str]: @property def active_branch(self) -> Head: - """The name of the currently active branch. + """The currently active branch. + + Check ``repo.head.is_detached`` before accessing this property if HEAD + may be detached. To access the current commit in either state, use + ``repo.head.commit`` instead. :raise TypeError: If HEAD is detached. diff --git a/test/lib/helper.py b/test/lib/helper.py index 4135fe5dd..b4399ca62 100644 --- a/test/lib/helper.py +++ b/test/lib/helper.py @@ -139,7 +139,8 @@ def wrapper(self, *args, **kwargs): def with_rw_repo(working_tree_ref, bare=False): """Same as with_bare_repo, but clones the rorepo as non-bare repository, checking - out the working tree at the given working_tree_ref. + out the working tree at the given working_tree_ref with an attached HEAD, + regardless of the source repository's HEAD state. This repository type is more costly due to the working copy checkout. @@ -158,7 +159,12 @@ def repo_creator(self): repo_dir = tempfile.mktemp(prefix="%sbare_%s" % (prefix, func.__name__)) rw_repo = self.rorepo.clone(repo_dir, shared=True, bare=bare, n=True) - rw_repo.head.commit = rw_repo.commit(working_tree_ref) + if rw_repo.head.is_detached: + rw_repo.head.reference = rw_repo.create_head( + "master", working_tree_ref, force=True, logmsg="Create test branch" + ) + else: + rw_repo.head.commit = rw_repo.commit(working_tree_ref) if not bare: rw_repo.head.reference.checkout() # END handle checkout @@ -294,6 +300,7 @@ def remote_repo_creator(self): rw_repo_dir = tempfile.mktemp(prefix="daemon_cloned_repo-%s-" % func.__name__) rw_daemon_repo = self.rorepo.clone(rw_daemon_repo_dir, shared=True, bare=True) + rw_daemon_repo.head.reference = rw_daemon_repo.create_head("master", force=True) # Recursive alternates info? rw_repo = rw_daemon_repo.clone(rw_repo_dir, shared=True, bare=False, n=True) try: