Skip to content

Commit ff1b1f9

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 5cc196a commit ff1b1f9

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
@@ -648,6 +648,64 @@ def _var(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
648648
return signature.name + b" <" + signature.email + b"> " + _date(signature) + b"\n"
649649

650650

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

‎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

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

202202

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