feat!: return None for detached refs and add HEAD.hexsha (#2230) · gitpython-developers/GitPython@dd6f1c8 · GitHub
Skip to content

Commit dd6f1c8

Browse files
codexByron
authored andcommitted
feat!: return None for detached refs and add HEAD.hexsha (#2230)
A detached HEAD currently turns a branch lookup into a TypeError. Return None from SymbolicReference.reference (and its ref alias) and Repo.active_branch. An unborn branch still exposes its reference. Add repo.head.hexsha to read the hexadecimal object ID directly without loading commit objects. Resolve loose and packed references in attached, detached, bare, and linked-worktree repositories. Return None for an unborn target, and raise for a missing HEAD, malformed or cyclic refs, and failures to read reference data. Distinguish missing references from other I/O errors so an unreadable loose ref cannot silently fall back to a stale packed value. Replace the empty-ref assertion with ValueError and detect symbolic-reference cycles. Preserve revision lookup for branch names that overlap Git metadata paths. Update conditional configuration includes, reflog and upstream lookups, commit creation, checkout, and submodule tracking to handle optional references. Relative submodule URLs use origin when HEAD is detached. Update the tutorial and regression tests for the new API. Make writable test clones usable from detached checkouts, give remote tests an explicit master branch, and compare reflog results with Git instead of assuming successive entries always name different commits. Validation: - 311 targeted tests passed using disposable clones and isolated Git configuration, with additional detached-HEAD and feature-branch checks. - Ruff lint and formatting checks passed; mypy passed for 45 source files. - The pre-existing test_writer_rejects_invalid_option_names failure remains: it expects an underscore-containing option name to be accepted, while the config parser correctly rejects that Git-incompatible name.
1 parent 4c260e4 commit dd6f1c8

16 files changed

Lines changed: 251 additions & 90 deletions

File tree

doc/source/tutorial.rst

Lines changed: 9 additions & 0 deletions

git/config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181

8282
_MISSING = object()
8383

84+
8485
def _escape_section_subsection(value: str) -> str:
8586
"""Return *value* escaped for Git's double-quoted subsection syntax."""
8687
return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -819,13 +820,12 @@ def _all_items(section: str) -> List[Tuple[str, str]]:
819820
paths += _all_items(section)
820821

821822
elif keyword == "onbranch":
822-
try:
823-
branch_name = self._repo.active_branch.name
824-
except TypeError:
823+
branch = self._repo.active_branch
824+
if branch is None:
825825
# Ignore section if active branch cannot be retrieved.
826826
continue
827827

828-
if fnmatch.fnmatchcase(branch_name, value):
828+
if fnmatch.fnmatchcase(branch.name, value):
829829
paths += _all_items(section)
830830
elif keyword == "hasconfig:remote.*.url":
831831
for remote in self._repo.remotes:

git/objects/commit.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,9 +777,12 @@ def create_from_tree(
777777
except ValueError:
778778
# head is not yet set to the ref our HEAD points to.
779779
# Happens on first commit.
780+
reference = repo.head.ref
781+
if reference is None:
782+
raise
780783
master = git.refs.Head.create(
781784
repo,
782-
repo.head.ref,
785+
reference,
783786
new_commit,
784787
logmsg="commit (initial): %s" % message,
785788
)

git/objects/submodule/base.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
if TYPE_CHECKING:
7272
from git.index import IndexFile
7373
from git.objects.commit import Commit
74-
from git.refs import Head, RemoteReference
74+
from git.refs import Head
7575
from git.repo import Repo
7676

7777
# -----------------------------------------------------------------------------
@@ -381,7 +381,9 @@ def _clone_repo(
381381
module_checkout_path = osp.join(repo.working_tree_dir, path) # type: ignore[arg-type]
382382

383383
if url.startswith("../"):
384-
remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name
384+
branch = repo.active_branch
385+
tracking_branch = branch.tracking_branch() if branch is not None else None
386+
remote_name = tracking_branch.remote_name if tracking_branch is not None else "origin"
385387
repo_remote_url = repo.remote(remote_name).url
386388
url = os.path.join(repo_remote_url, url)
387389

@@ -876,7 +878,7 @@ def fetch_remotes(module_repo: "Repo") -> None:
876878
local_branch,
877879
logmsg="submodule: attaching head to %s" % local_branch,
878880
)
879-
mrepo.head.reference.set_tracking_branch(remote_branch)
881+
local_branch.set_tracking_branch(remote_branch)
880882
except (IndexError, InvalidGitRepositoryError):
881883
_logger.warning("Failed to checkout tracking branch %s", self.branch_path)
882884

@@ -897,8 +899,9 @@ def fetch_remotes(module_repo: "Repo") -> None:
897899

898900
if mrepo is not None and to_latest_revision:
899901
msg_base = "Cannot update to latest revision in repository at %r as " % mrepo.working_dir
900-
if not is_detached:
901-
rref = mrepo.head.reference.tracking_branch()
902+
branch = mrepo.active_branch
903+
if branch is not None:
904+
rref = branch.tracking_branch()
902905
if rref is not None:
903906
rcommit = rref.commit
904907
binsha = rcommit.binsha
@@ -907,7 +910,7 @@ def fetch_remotes(module_repo: "Repo") -> None:
907910
_logger.error(
908911
"%s a tracking branch was not set for local branch '%s'",
909912
msg_base,
910-
mrepo.head.reference,
913+
branch,
911914
)
912915
# END handle remote ref
913916
else:

git/refs/head.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from git.util import join_path
1414

1515
from .reference import Reference
16-
from .symbolic import SymbolicReference
16+
from .symbolic import SymbolicReference, _ReferenceNotFoundError
1717

1818
# typing ---------------------------------------------------
1919

@@ -49,6 +49,27 @@ def __init__(self, repo: "Repo", path: PathLike = _HEAD_NAME) -> None:
4949
raise ValueError("HEAD instance must point to %r, got %r" % (self._HEAD_NAME, path))
5050
super().__init__(repo, path)
5151

52+
@property
53+
def hexsha(self) -> Union[str, None]:
54+
"""HEAD's hexadecimal object ID, or ``None`` if its target is unborn.
55+
56+
Resolve symbolic references without reading the object database. This works
57+
for attached and detached HEADs, including bare repositories and worktrees.
58+
59+
:raise ValueError:
60+
If HEAD is missing or reference data is malformed or cyclic.
61+
62+
:raise OSError:
63+
If HEAD, its target, or packed references cannot be read.
64+
"""
65+
hexsha, ref_path = self._get_ref_info(self.repo, self.path)
66+
if ref_path is None:
67+
return hexsha
68+
try:
69+
return self.dereference_recursive(self.repo, ref_path)
70+
except _ReferenceNotFoundError:
71+
return None
72+
5273
def orig_head(self) -> SymbolicReference:
5374
"""
5475
:return:
@@ -291,10 +312,7 @@ def checkout(
291312
kwargs.pop("f")
292313

293314
self.repo.git.checkout(self, **kwargs)
294-
if self.repo.head.is_detached:
295-
return self.repo.head
296-
else:
297-
return self.repo.active_branch
315+
return self.repo.active_branch or self.repo.head
298316

299317
# { Configuration
300318
def _config_parser(self, read_only: bool) -> SectionConstraint[GitConfigParser]:

git/refs/symbolic.py

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
# ------------------------------------------------------------------------------
5151

5252

53+
class _ReferenceNotFoundError(ValueError):
54+
"""A reference has neither a loose nor a packed entry."""
55+
56+
5357
def _git_dir(repo: "Repo", path: Union[PathLike, None]) -> PathLike:
5458
"""Find the git dir that is appropriate for the path."""
5559
name = f"{path}"
@@ -176,7 +180,7 @@ def _iter_packed_refs(cls, repo: "Repo") -> Iterator[Tuple[str, str]]:
176180

177181
yield cast(Tuple[str, str], tuple(line.split(" ", 1)))
178182
# END for each line
179-
except OSError:
183+
except FileNotFoundError:
180184
return None
181185
# END no packed-refs file handling
182186

@@ -191,8 +195,13 @@ def dereference_recursive(cls, repo: "Repo", ref_path: Union[PathLike, None]) ->
191195
The repository containing the reference at `ref_path`.
192196
"""
193197

198+
seen = set()
194199
while True:
195-
hexsha, ref_path = cls._get_ref_info(repo, ref_path)
200+
path = os.fspath(ref_path) if ref_path is not None else None
201+
if path in seen:
202+
raise ValueError("Symbolic reference cycle at %r" % path)
203+
seen.add(path)
204+
hexsha, ref_path = cls._get_ref_info(repo, path)
196205
if hexsha is not None:
197206
return hexsha
198207
# END recursive dereferencing
@@ -267,8 +276,7 @@ def _get_ref_info_helper(
267276
# Don't only split on spaces, but on whitespace, which allows to parse lines like:
268277
# 60b64ef992065e2600bfef6187a97f92398a9144 branch 'master' of git-server:/path/to/repo
269278
tokens = value.split()
270-
assert len(tokens) != 0
271-
except OSError:
279+
except FileNotFoundError:
272280
# Probably we are just packed. Find our entry in the packed refs file.
273281
# NOTE: We are not a symbolic ref if we are in a packed file, as these
274282
# are excluded explicitly.
@@ -281,14 +289,14 @@ def _get_ref_info_helper(
281289
# END for each packed ref
282290
# END handle packed refs
283291
if tokens is None:
284-
raise ValueError("Reference at %r does not exist" % ref_path)
292+
raise _ReferenceNotFoundError("Reference at %r does not exist" % ref_path)
285293

286294
# Is it a reference?
287-
if tokens[0] == "ref:":
295+
if len(tokens) == 2 and tokens[0] == "ref:":
288296
return (None, tokens[1])
289297

290298
# It's a commit.
291-
if repo.re_hexsha_only.match(tokens[0]):
299+
if tokens and repo.re_hexsha_only.match(tokens[0]):
292300
return (tokens[0], None)
293301

294302
raise ValueError("Failed to parse reference information from %r" % ref_path)
@@ -401,18 +409,18 @@ def set_object(
401409
object = object.object # @ReservedAssignment
402410
# END resolve references
403411

404-
is_detached = True
412+
reference = None
405413
try:
406-
is_detached = self.is_detached
414+
reference = self._get_reference()
407415
except ValueError:
408416
pass
409417
# END handle non-existing ones
410418

411-
if is_detached:
419+
if reference is None:
412420
return self.set_reference(object, logmsg)
413421

414422
# set the commit on our reference
415-
return self._get_reference().set_object(object, logmsg)
423+
return reference.set_object(object, logmsg)
416424

417425
@property
418426
def commit(self) -> "Commit":
@@ -432,18 +440,11 @@ def object(self) -> AnyGitObject:
432440
def object(self, object: Union[AnyGitObject, "SymbolicReference", str]) -> "SymbolicReference":
433441
return self.set_object(object)
434442

435-
def _get_reference(self) -> "Reference":
436-
"""
437-
:return:
438-
:class:`~git.refs.reference.Reference` object we point to
439-
440-
:raise TypeError:
441-
If this symbolic reference is detached, hence it doesn't point to a
442-
reference, but to a commit.
443-
"""
444-
sha, target_ref_path = self._get_ref_info(self.repo, self.path)
443+
def _get_reference(self) -> Union["Reference", None]:
444+
"""Return the reference we point to, or ``None`` if detached."""
445+
_sha, target_ref_path = self._get_ref_info(self.repo, self.path)
445446
if target_ref_path is None:
446-
raise TypeError("%s is a detached symbolic reference as it points to %r" % (self, sha))
447+
return None
447448
return cast("Reference", self.from_path(self.repo, target_ref_path))
448449

449450
def set_reference(
@@ -530,7 +531,13 @@ def set_reference(
530531

531532
# Aliased reference
532533
@property
533-
def reference(self) -> "Reference":
534+
def reference(self) -> Union["Reference", None]:
535+
"""The reference we point to, or ``None`` if detached.
536+
537+
An unborn branch still has a reference. Missing or malformed reference data
538+
raises :exc:`ValueError`; other read errors propagate as :exc:`OSError`.
539+
For HEAD's object ID, use :attr:`~git.refs.head.HEAD.hexsha`.
540+
"""
534541
return self._get_reference()
535542

536543
@reference.setter
@@ -559,11 +566,7 @@ def is_detached(self) -> bool:
559566
``True`` if we are a detached reference, hence we point to a specific commit
560567
instead to another reference.
561568
"""
562-
try:
563-
self.ref # noqa: B018
564-
return False
565-
except TypeError:
566-
return True
569+
return self.ref is None
567570

568571
def log(self) -> "RefLog":
569572
"""

git/repo/base.py

Lines changed: 8 additions & 7 deletions

0 commit comments

Comments
 (0)