Skip to content

Commit e2201f9

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 bb2b59a commit e2201f9

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
@@ -555,6 +555,52 @@ def _read_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
555555
return b""
556556

557557

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

‎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

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

5757

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

102141

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

0 commit comments

Comments
 (0)