Skip to content

Commit 66b8ad5

Browse files
codexByron
authored andcommitted
feat(gix): create unsigned UTF-8 commits natively
Implement managed `commit-tree` with explicit author/committer signatures and the native commit writer. Verify tree and parent kinds before mutation and preserve message bytes. Other encodings, duplicate parents, signing and unsupported options keep Git's behavior. Add combined object/tree/index/commit checks that reject subprocesses and compare native object IDs with Git. The staged version passes Ruff and Python syntax checks. Its SHA-1/SHA-256 native smoke and focused pytest selection pass: 55 passed in 9.23s.
1 parent b48f13c commit 66b8ad5

2 files changed

Lines changed: 102 additions & 1 deletion

File tree

‎git/_backend.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,52 @@ def _read_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
551551
return b""
552552

553553

554+
def _checked_signature(signature: Any) -> Any:
555+
# Git strips "crud" at the edges and angle brackets/newlines within identities.
556+
crud = bytes(range(33)) + b".,:;<>\"'\\"
557+
for value in (signature.name, signature.email):
558+
if not value or value != value.strip(crud) or any(char in value for char in b"<>\r\n\0"):
559+
raise _Unsupported("identity normalization")
560+
return signature
561+
562+
563+
def _signature(env: Dict[str, Any], role: str) -> Any:
564+
prefix = "GIT_" + role + "_"
565+
name, email, date = (env.get(prefix + field) for field in ("NAME", "EMAIL", "DATE"))
566+
match = re.fullmatch(r"(-?\d+) ([+-])(\d\d)(\d\d)", date or "")
567+
if not name or not email or match is None:
568+
raise _Unsupported("identity resolution")
569+
seconds, sign, hours, minutes = match.groups()
570+
offset = (int(hours) * 60 + int(minutes)) * 60 * (-1 if sign == "-" else 1)
571+
return _checked_signature(gix.Signature(name, email, int(seconds), offset))
572+
573+
574+
def _commit_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
575+
if len(args) < 2 or args[0] != "--no-gpg-sign" or args[2::2] != ["-p"] * len(args[2::2]):
576+
raise _Unsupported("commit options")
577+
if len(args) % 2 or any(setting.lower() != "i18n.commitencoding=utf-8" for setting in kwargs.get("_config", ())):
578+
raise _Unsupported("commit encoding (GIX-6)")
579+
data = _input(kwargs)
580+
try:
581+
message = data.decode("utf-8")
582+
except UnicodeDecodeError:
583+
raise _Unsupported("commit message bytes (GIX-6)") from None
584+
parents = args[3::2]
585+
if len(set(parents)) != len(parents):
586+
raise _Unsupported("duplicate parents")
587+
author = _signature(kwargs.get("env", {}), "AUTHOR")
588+
committer = _signature(kwargs.get("env", {}), "COMMITTER")
589+
tree = _oid(repo, args[1])
590+
# Native commit creation accepts literal IDs, whereas commit-tree verifies kinds.
591+
if repo.find_header(tree).kind() != "tree" or any(
592+
repo.find_header(_oid(repo, p)).kind() != "commit" for p in parents
593+
):
594+
raise _Unsupported("commit object kinds")
595+
commit = _write("commit_tree", lambda: repo.new_commit_as(committer, author, message, tree, parents))
596+
kwargs["istream"].seek(len(data), 1)
597+
return str(commit.id).encode("ascii") + b"\n"
598+
599+
554600
_HANDLERS: Dict[str, Callable[[Any, List[str], Dict[str, Any]], bytes]] = {
555601
"rev_parse": _rev_parse,
556602
"ls_tree": _ls_tree,
@@ -562,6 +608,7 @@ def _read_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
562608
"hash_object": _hash_object,
563609
"mktree": _mktree,
564610
"read_tree": _read_tree,
611+
"commit_tree": _commit_tree,
565612
"config": _config,
566613
"worktree": _worktree,
567614
}

‎test/test_gix_backend.py‎

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

88
import pytest
99

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

@@ -54,6 +54,45 @@ def test_native_worktree_inventory_includes_bare_main(repo, tmp_path):
5454
assert current.git._call_process_safe("worktree", "list", "--porcelain", "-z") == expected
5555

5656

57+
def test_native_objects_trees_commits_and_index_reads(repo):
58+
actor = Actor("Example", "example@example.invalid")
59+
paths = ["file", "dir/with space", "dir/unicode-é", "--option"]
60+
for name in paths:
61+
path = Path(repo.working_dir, name)
62+
path.parent.mkdir(exist_ok=True)
63+
path.write_bytes(name.encode())
64+
index = repo.index
65+
# All setup has finished; these operations must succeed without a subprocess.
66+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected CLI call")):
67+
index.add(paths)
68+
tree = index.write_tree()
69+
assert {blob.path for blob in tree.traverse() if blob.type == "blob"} == set(paths)
70+
assert tree["file"].data_stream.read() == b"file"
71+
assert set(repo.index.entries) == {(name, 0) for name in paths}
72+
stream = IStream("blob", 4, BytesIO(b"data"))
73+
assert repo.odb.store(stream) is stream
74+
assert repo.odb.stream(stream.binsha).read(2) == b"da"
75+
commit = index.commit("message without a trailing newline", author=actor, committer=actor, skip_hooks=True)
76+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected CLI call")):
77+
assert repo.commit(commit.hexsha).message == "message without a trailing newline"
78+
assert repo.merge_base(commit, commit) == [commit]
79+
assert repo.is_ancestor(commit, commit)
80+
assert commit.replace(message="changed").message == "changed"
81+
82+
83+
def test_native_tree_and_commit_bytes_match_cli(repo):
84+
actor = Actor("Example", "example@example.invalid")
85+
Path(repo.working_dir, "file").write_bytes(b"payload\n")
86+
index = repo.index
87+
index.add(["file"])
88+
native_tree = index.write_tree()
89+
commit = index.commit("message", author=actor, committer=actor, skip_hooks=True)
90+
native = commit.replace(message="a changed message")
91+
with patch.object(_backend, "gix", None):
92+
assert index.write_tree().hexsha == native_tree.hexsha
93+
assert commit.replace(message="a changed message").hexsha == native.hexsha
94+
95+
5796
def test_unknown_command_and_storage_environment_use_cli(repo):
5897
before = _backend.statistics()
5998
assert repo.git._call_process_safe("status", "--porcelain") == ""
@@ -99,6 +138,21 @@ def test_tree_writes_validate_child_object_kinds(repo, mode, kind):
99138
repo.git._call_process_safe("mktree", "-z", "--missing", istream=stream)
100139

101140

141+
def test_commit_identity_cleanup_matches_git(repo):
142+
actor = Actor(" Name. ", " email@example.invalid ")
143+
tree = repo.index.write_tree()
144+
kwargs = {
145+
"author": actor,
146+
"committer": actor,
147+
"author_date": "1100000000 +0000",
148+
"commit_date": "1100000000 +0000",
149+
"parent_commits": [],
150+
}
151+
commit = Commit.create_from_tree(repo, tree, "message", **kwargs)
152+
with patch.object(_backend, "gix", None):
153+
assert commit == Commit.create_from_tree(repo, tree, "message", **kwargs)
154+
155+
102156
def test_partial_native_stream_does_not_corrupt_next_read(repo):
103157
one = repo.odb.store(IStream("blob", 6, BytesIO(b"abcdef"))).binsha
104158
two = repo.odb.store(IStream("blob", 3, BytesIO(b"xyz"))).binsha

0 commit comments

Comments
 (0)