Skip to content

Commit 4a64617

Browse files
codexByron
authored andcommitted
feat(gix): update supported reference targets natively
Use native reference edits for compatible unconstrained target changes and non-dereferencing deletions. Keep strict create/CAS, active-branch HEAD logs, symbolic detachment and unchanged-target cases on Git because the native semantics differ (`GIX-9`/`GIX-10`). Pass canonical log messages unchanged to `gix.LogChange`. Messages needing whitespace cleanup use Git before any native mutation; do not repair missing native cleanup in Python. GixPython/Gitoxide must expose Git-compatible log message handling before these cases can become native. A compatible update avoids one CLI call, while a cleanup case intentionally retains one instead of the previous zero-call Python substitute. Per-command committer overrides also retain the upstream identity-resolution fallback (`GIX-21`). Regression tests retain native history/reference parity and compare cleanup cases against Git, asserting fallback before `_write()` can run. Validation: backend/reference tests passed with Gix first (79 passed and 24 subtests in 22.73s), then CLI (39 passed, 1 skipped and 24 subtests in 28.30s). Ruff lint/format and `git diff --check` passed.
1 parent aeea1c4 commit 4a64617

3 files changed

Lines changed: 116 additions & 3 deletions

File tree

‎git/_backend.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,63 @@ def _var(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
639639
return signature.name + b" <" + signature.email + b"> " + _date(signature) + b"\n"
640640

641641

642+
def _update_ref(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
643+
if "--" not in args:
644+
raise _Unsupported("reference transactions (GIX-9)")
645+
split = args.index("--")
646+
options, operands = args[:split], args[split + 1 :]
647+
deref, force_log, delete, message = True, False, False, ""
648+
cursor = iter(options)
649+
for option in cursor:
650+
if option == "--no-deref":
651+
deref = False
652+
elif option == "--create-reflog":
653+
force_log = True
654+
elif option == "-d":
655+
delete = True
656+
elif option == "-m":
657+
message = next(cursor, "")
658+
else:
659+
raise _Unsupported("reference options")
660+
if re.search(r"[\t\r\n\v\f]|^ | $| {2}", message):
661+
raise _Unsupported("reflog message cleanup (GIX-10)")
662+
if delete:
663+
if len(operands) != 1 or deref:
664+
raise _Unsupported("reference deletion options")
665+
edit = gix.RefEdit.delete(operands[0], gix.PreviousValue.Any).with_deref(False)
666+
_write("update_ref", lambda: repo.edit_references_as([edit]))
667+
return b""
668+
if len(operands) != 2:
669+
raise _Unsupported("strict reference creation / compare-and-swap (GIX-9)")
670+
name, target = operands
671+
oid = _oid(repo, target)
672+
kind = repo.find_header(oid).kind()
673+
if (name == "HEAD" or name.startswith("refs/heads/")) and kind != "commit":
674+
raise _Unsupported("branch target validation")
675+
reference = repo.try_find_reference(name)
676+
if reference is not None:
677+
if deref and name != "HEAD" and reference.target().try_name() is not None:
678+
raise _Unsupported("symbolic reference update (GIX-10)")
679+
if not deref and reference.target().try_name() is not None:
680+
raise _Unsupported("detaching symbolic ref reflog (GIX-10)")
681+
if reference.follow_to_object() == oid:
682+
raise _Unsupported("unchanged reference reflog (GIX-10)")
683+
head = repo.try_find_reference("HEAD")
684+
for _ in range(5):
685+
if head is None or head.target().try_name() is None:
686+
break
687+
if head.target().try_name() == os.fsencode(name):
688+
raise _Unsupported("active branch HEAD reflog (GIX-10)")
689+
head = head.follow()
690+
log = gix.LogChange()
691+
log.force_create_reflog = force_log
692+
log.message = message
693+
edit = gix.RefEdit.update_with_log(name, gix.Target.Object(oid), gix.PreviousValue.Any, log).with_deref(deref)
694+
signature = _committer(repo, kwargs.get("env", {}))
695+
_write("update_ref", lambda: repo.edit_references_as([edit], signature))
696+
return b""
697+
698+
642699
def _commit_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
643700
if len(args) < 2 or args[0] != "--no-gpg-sign" or args[2::2] != ["-p"] * len(args[2::2]):
644701
raise _Unsupported("commit options")
@@ -679,6 +736,7 @@ def _commit_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
679736
"commit_tree": _commit_tree,
680737
"reflog": _reflog,
681738
"var": _var,
739+
"update_ref": _update_ref,
682740
"config": _config,
683741
"worktree": _worktree,
684742
}

‎test/test_gix_backend.py‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import pytest
99

10-
from git import Actor, Commit, Git, Repo, _backend
10+
from git import Actor, Commit, Git, Reference, Repo, _backend
1111
from git.exc import GitCommandError
1212
from gitdb import IStream
1313

@@ -166,6 +166,16 @@ def test_commit_identity_cleanup_matches_git(repo):
166166
assert commit == Commit.create_from_tree(repo, tree, "message", **kwargs)
167167

168168

169+
def test_symbolic_alias_cannot_point_a_branch_at_a_blob(repo):
170+
commit = repo.index.commit("initial", skip_hooks=True)
171+
blob = repo.odb.store(IStream("blob", 1, BytesIO(b"x")))
172+
repo.git.symbolic_ref("refs/aliases/main", "refs/heads/main")
173+
alias = Reference(repo, "refs/aliases/main")
174+
with pytest.raises(GitCommandError):
175+
alias.set_object(blob.hexsha.decode())
176+
assert repo.head.commit == commit
177+
178+
169179
def test_partial_native_stream_does_not_corrupt_next_read(repo):
170180
one = repo.odb.store(IStream("blob", 6, BytesIO(b"abcdef"))).binsha
171181
two = repo.odb.store(IStream("blob", 3, BytesIO(b"xyz"))).binsha
@@ -174,3 +184,45 @@ def test_partial_native_stream_does_not_corrupt_next_read(repo):
174184
assert stream.read(1) == b"a"
175185
assert repo.odb.stream(two).read() == b"xyz"
176186
assert stream.read() == b"bcdef"
187+
188+
189+
def test_native_history_and_references(repo, monkeypatch):
190+
monkeypatch.setenv("GIT_COMMITTER_NAME", "Reflog Actor")
191+
monkeypatch.setenv("GIT_COMMITTER_EMAIL", "actor@example.invalid")
192+
monkeypatch.setenv("GIT_COMMITTER_DATE", "1100000000 -0430")
193+
first = repo.index.commit("first", skip_hooks=True)
194+
second = first.replace(message="second", parents=[first])
195+
branch = repo.create_head("other", first)
196+
repo.heads # Warm the existing, Git-validated reference-name cache.
197+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected CLI call")):
198+
repo.head.set_object(second, "through HEAD")
199+
branch.set_object(second, "inactive branch")
200+
assert first.count() == 1
201+
assert second.count() == 2
202+
assert list(repo.iter_commits(first_parent=True, skip=1)) == [first]
203+
assert list(repo.iter_commits(max_count=1)) == [second]
204+
assert repo.head.log_entry(-1).message == "through HEAD"
205+
assert branch.log_entry(-1).newhexsha == second.hexsha
206+
native_log = repo.head.log()
207+
native_refs = [ref.path for ref in repo.heads]
208+
with patch.object(_backend, "gix", None):
209+
assert native_log == repo.head.log()
210+
assert native_refs == [ref.path for ref in repo.heads]
211+
212+
213+
@pytest.mark.parametrize("message", ["through\tHEAD", " leading spaces ", "line\nnext", "mixed\r\v\fspaces"])
214+
def test_reflog_message_cleanup_uses_cli_before_mutation(repo, message, monkeypatch):
215+
monkeypatch.setenv("GIT_COMMITTER_DATE", "1100000000 -0430")
216+
first = repo.index.commit("first", skip_hooks=True)
217+
second = first.replace(message="second", parents=[first])
218+
branch = repo.create_head("other", first)
219+
control = repo.create_head("control", first)
220+
args = ("--create-reflog", "-m", message, "--")
221+
with patch.object(_backend, "_write", side_effect=AssertionError("must fall back before mutation")):
222+
with patch.object(Git, "execute", autospec=True, side_effect=Git.execute) as cli:
223+
repo.git._call_process_safe("update_ref", *args, branch.path, second.hexsha)
224+
assert cli.call_count == 1
225+
native_log = branch.log_entry(-1)
226+
with patch.object(_backend, "gix", None):
227+
repo.git._call_process_safe("update_ref", *args, control.path, second.hexsha)
228+
assert native_log == control.log_entry(-1)

‎test/test_positional_args.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from git import Actor, Git, GitCommandError, Head, Remote, RemoteReference, Repo, TagReference
7+
from git import Actor, Git, GitCommandError, Head, Remote, RemoteReference, Repo, TagReference, _backend
88
from git.exc import UnsafeOptionError
99

1010

@@ -95,7 +95,10 @@ def test_remote_ref_delete_preserves_operand(tmp_path):
9595
commit = repo.index.commit("initial")
9696
ref = RemoteReference(repo, "refs/remotes/--force")
9797
repo.git.update_ref(ref.path, commit.hexsha)
98-
with mock.patch.object(Git, "execute", autospec=True, side_effect=Git.execute) as run:
98+
# Exercise the CLI fallback's option boundary even with GixPython installed.
99+
with mock.patch.object(_backend, "dispatch", return_value=NotImplemented), mock.patch.object(
100+
Git, "execute", autospec=True, side_effect=Git.execute
101+
) as run:
99102
RemoteReference.delete(repo, ref)
100103
argv = run.call_args.args[1]
101104
assert argv[argv.index("update-ref") :] == ["update-ref", "--no-deref", "-d", "--", ref.path]

0 commit comments

Comments
 (0)