Skip to content

Commit 64d089a

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`). Set each edit's message through `gix.LogChange`, including its force-log flag. The API already provides explicit per-edit messages, but no Git-compatible cleanup policy: a direct native write preserves leading/trailing whitespace, tabs, and repeated spaces where Git stores `keep spaces`. Add a regression that writes through `edit_references_as()` and reads both native and Git logs. Pass canonical messages unchanged. Messages needing cleanup use Git before any native mutation; Python must not repair missing native cleanup. Upstream could provide normalization on `LogChange` or a policy applied to all ref edits. `core.logAllRefUpdates` controls log creation, not message cleanup. A supported update avoids one CLI call; a cleanup case retains one. This follow-up clarifies the limitation and leaves those counts unchanged. Per-command committer overrides also retain their `GIX-21` fallback. Validation: reflog/history/positional selection passed with Gix first (45 passed in 7.87s), then CLI (33 passed, 1 skipped in 7.48s). Ruff lint/format and `git diff --check` passed. The replay resolution preserves both `Reference` and `InvalidGitRepositoryError` imports and existing tests.
1 parent d281b78 commit 64d089a

3 files changed

Lines changed: 137 additions & 3 deletions

File tree

‎git/_backend.py‎

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

654654

655+
def _update_ref(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
656+
if "--" not in args:
657+
raise _Unsupported("reference transactions (GIX-9)")
658+
split = args.index("--")
659+
options, operands = args[:split], args[split + 1 :]
660+
deref, force_log, delete, message = True, False, False, ""
661+
cursor = iter(options)
662+
for option in cursor:
663+
if option == "--no-deref":
664+
deref = False
665+
elif option == "--create-reflog":
666+
force_log = True
667+
elif option == "-d":
668+
delete = True
669+
elif option == "-m":
670+
message = next(cursor, "")
671+
else:
672+
raise _Unsupported("reference options")
673+
# LogChange sets each edit's message, but its writer does not normalize it.
674+
if re.search(r"[\t\r\n\v\f]|^ | $| {2}", message):
675+
raise _Unsupported("reflog message cleanup (GIX-10)")
676+
if delete:
677+
if len(operands) != 1 or deref:
678+
raise _Unsupported("reference deletion options")
679+
edit = gix.RefEdit.delete(operands[0], gix.PreviousValue.Any).with_deref(False)
680+
_write("update_ref", lambda: repo.edit_references_as([edit]))
681+
return b""
682+
if len(operands) != 2:
683+
raise _Unsupported("strict reference creation / compare-and-swap (GIX-9)")
684+
name, target = operands
685+
oid = _oid(repo, target)
686+
kind = repo.find_header(oid).kind()
687+
if (name == "HEAD" or name.startswith("refs/heads/")) and kind != "commit":
688+
raise _Unsupported("branch target validation")
689+
reference = repo.try_find_reference(name)
690+
if reference is not None:
691+
if deref and name != "HEAD" and reference.target().try_name() is not None:
692+
raise _Unsupported("symbolic reference update (GIX-10)")
693+
if not deref and reference.target().try_name() is not None:
694+
raise _Unsupported("detaching symbolic ref reflog (GIX-10)")
695+
if reference.follow_to_object() == oid:
696+
raise _Unsupported("unchanged reference reflog (GIX-10)")
697+
head = repo.try_find_reference("HEAD")
698+
for _ in range(5):
699+
if head is None or head.target().try_name() is None:
700+
break
701+
if head.target().try_name() == os.fsencode(name):
702+
raise _Unsupported("active branch HEAD reflog (GIX-10)")
703+
head = head.follow()
704+
log = gix.LogChange()
705+
log.force_create_reflog = force_log
706+
log.message = message
707+
edit = gix.RefEdit.update_with_log(name, gix.Target.Object(oid), gix.PreviousValue.Any, log).with_deref(deref)
708+
signature = _committer(repo, kwargs.get("env", {}))
709+
_write("update_ref", lambda: repo.edit_references_as([edit], signature))
710+
return b""
711+
712+
655713
def _commit_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
656714
if len(args) < 2 or args[0] != "--no-gpg-sign" or args[2::2] != ["-p"] * len(args[2::2]):
657715
raise _Unsupported("commit options")
@@ -692,6 +750,7 @@ def _commit_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
692750
"commit_tree": _commit_tree,
693751
"reflog": _reflog,
694752
"var": _var,
753+
"update_ref": _update_ref,
695754
"config": _config,
696755
"worktree": _worktree,
697756
}

‎test/test_gix_backend.py‎

Lines changed: 73 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, InvalidGitRepositoryError
1212
from gitdb import IStream
1313

@@ -201,6 +201,16 @@ def test_commit_identity_cleanup_matches_git(repo):
201201
assert commit == Commit.create_from_tree(repo, tree, "message", **kwargs)
202202

203203

204+
def test_symbolic_alias_cannot_point_a_branch_at_a_blob(repo):
205+
commit = repo.index.commit("initial", skip_hooks=True)
206+
blob = repo.odb.store(IStream("blob", 1, BytesIO(b"x")))
207+
repo.git.symbolic_ref("refs/aliases/main", "refs/heads/main")
208+
alias = Reference(repo, "refs/aliases/main")
209+
with pytest.raises(GitCommandError):
210+
alias.set_object(blob.hexsha.decode())
211+
assert repo.head.commit == commit
212+
213+
204214
def test_partial_native_stream_does_not_corrupt_next_read(repo):
205215
one = repo.odb.store(IStream("blob", 6, BytesIO(b"abcdef"))).binsha
206216
two = repo.odb.store(IStream("blob", 3, BytesIO(b"xyz"))).binsha
@@ -209,3 +219,65 @@ def test_partial_native_stream_does_not_corrupt_next_read(repo):
209219
assert stream.read(1) == b"a"
210220
assert repo.odb.stream(two).read() == b"xyz"
211221
assert stream.read() == b"bcdef"
222+
223+
224+
def test_native_history_and_references(repo, monkeypatch):
225+
monkeypatch.setenv("GIT_COMMITTER_NAME", "Reflog Actor")
226+
monkeypatch.setenv("GIT_COMMITTER_EMAIL", "actor@example.invalid")
227+
monkeypatch.setenv("GIT_COMMITTER_DATE", "1100000000 -0430")
228+
first = repo.index.commit("first", skip_hooks=True)
229+
second = first.replace(message="second", parents=[first])
230+
branch = repo.create_head("other", first)
231+
repo.heads # Warm the existing, Git-validated reference-name cache.
232+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected CLI call")):
233+
repo.head.set_object(second, "through HEAD")
234+
branch.set_object(second, "inactive branch")
235+
assert first.count() == 1
236+
assert second.count() == 2
237+
assert list(repo.iter_commits(first_parent=True, skip=1)) == [first]
238+
assert list(repo.iter_commits(max_count=1)) == [second]
239+
assert repo.head.log_entry(-1).message == "through HEAD"
240+
assert branch.log_entry(-1).newhexsha == second.hexsha
241+
native_log = repo.head.log()
242+
native_refs = [ref.path for ref in repo.heads]
243+
with patch.object(_backend, "gix", None):
244+
assert native_log == repo.head.log()
245+
assert native_refs == [ref.path for ref in repo.heads]
246+
247+
248+
def test_native_refedit_message_is_verbatim(repo):
249+
commit = repo.index.commit("initial", skip_hooks=True)
250+
native = gix.open(repo.git_dir)
251+
log = gix.LogChange()
252+
log.force_create_reflog = True
253+
log.message = " keep\t spaces "
254+
edit = gix.RefEdit.update_with_log(
255+
"refs/heads/native", gix.Target.Object(gix.ObjectId(commit.hexsha)), gix.PreviousValue.Any, log
256+
)
257+
native.edit_references_as([edit], gix.Signature("Example", "example@example.invalid", 1100000000, 0))
258+
with native.find_reference("refs/heads/native").log_iter().rev() as lines:
259+
assert next(lines).message == b" keep\t spaces "
260+
with patch.object(_backend, "gix", None):
261+
repo.git._call_process_safe(
262+
"update_ref", "--create-reflog", "-m", " keep\t spaces ", "--", "refs/heads/control", commit.hexsha
263+
)
264+
with native.find_reference("refs/heads/control").log_iter().rev() as lines:
265+
assert next(lines).message == b"keep spaces"
266+
267+
268+
@pytest.mark.parametrize("message", ["through\tHEAD", " leading spaces ", "line\nnext", "mixed\r\v\fspaces"])
269+
def test_reflog_message_cleanup_uses_cli_before_mutation(repo, message, monkeypatch):
270+
monkeypatch.setenv("GIT_COMMITTER_DATE", "1100000000 -0430")
271+
first = repo.index.commit("first", skip_hooks=True)
272+
second = first.replace(message="second", parents=[first])
273+
branch = repo.create_head("other", first)
274+
control = repo.create_head("control", first)
275+
args = ("--create-reflog", "-m", message, "--")
276+
with patch.object(_backend, "_write", side_effect=AssertionError("must fall back before mutation")):
277+
with patch.object(Git, "execute", autospec=True, side_effect=Git.execute) as cli:
278+
repo.git._call_process_safe("update_ref", *args, branch.path, second.hexsha)
279+
assert cli.call_count == 1
280+
native_log = branch.log_entry(-1)
281+
with patch.object(_backend, "gix", None):
282+
repo.git._call_process_safe("update_ref", *args, control.path, second.hexsha)
283+
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)