diff --git a/doc/gix-backend.md b/doc/gix-backend.md index a1525be6e..49a6ae42d 100644 --- a/doc/gix-backend.md +++ b/doc/gix-backend.md @@ -966,6 +966,7 @@ identify the historical fixture where needed. | GIX-31 | Bug (open) | GixPython 0.2.0 `Commit.decode()` rejects commit headers containing a CR-only or tab-only line with `object parsing failed, at gix-object/src/commit/decode.rs:20`. Git 2.54.0 accepts both objects with `fsck --strict`, continues through their header block and renders the later Latin-1 declaration and message correctly. A space-only continuation line decodes natively but retains the late encoding declaration in `extra_headers` (GIX-30). `test_deserialization_headers_end_at_empty_line` reproduces all three using the `commit.sh whitespace-headers` fixtures and checks Git's projection. | Accept Git-compatible header lines and end headers only at an empty line. The existing `object_metadata()` native decoder diagnostics fallback retains Git for CR/tab inputs; the existing GIX-30 fallback covers the space input. Neither fallback closes these bugs. | | GIX-32 | Missing capability (open); no demonstrated Git compatibility mismatch | Windows CI on GixPython 0.3.0, Git 2.56.0.windows.2 and CPython 3.12.10 enumerates the non-UTF-8 packed names in `legacy-refs.sh`, but exact byte-name lookup selects `Reference.read: CLI: native read diagnostics`; the no-CLI guard fails for SHA-1 and SHA-256. POSIX lookup succeeds. Git's Windows CLI likewise cannot address those original bytes through UTF-16 arguments, so there is no demonstrated equivalent Windows CLI operand. Git enumeration does preserve both names. | Provide native Windows packed-reference lookup for byte names without first converting the name to a loose-reference filesystem path. Keep `reference_info()`'s existing diagnostic fallback; `test_non_utf8_tag_names_preserve_bytes` checks it on Windows. Resolution coverage and native claims remain POSIX-only. The upstream lookup source attempts loose-path conversion before consulting packed refs; CI demonstrates the fallback, but does not capture the underlying Gix error text. | | GIX-33 | Bug (open); missing exact-name mode | With Git 2.54.0 and GixPython 0.3.0, `exact-refs.sh sha1 files symbolic` makes `refs/tags/REBASE_HEAD` a symbolic ref to another tag while the root `REBASE_HEAD` is absent. `Repository.try_find_reference("REBASE_HEAD")` returns that tag; native `_symbolic_ref` formerly returned its target, whereas `git symbolic-ref --quiet --no-recurse -- REBASE_HEAD` exits 1. GitPython's root-reference object read consequently returned a commit through Gix and raised `ValueError` through CLI. SHA-256 reproduces the same mismatch. | Expose an exact-name lookup mode, including root references, whose absence and error behavior matches Git. `_symbolic_ref()` compares the native reference's name with the requested bytes and selects CLI for a lookalike. `test_missing_root_reference_does_not_follow_symbolic_lookalike_tag` verifies parity and forced CLI fallback. This adapter guard does not close the upstream compatibility bug. | +| GIX-34 | Bug (open) | With Git 2.54.0 and GixPython 0.3.0, an author, committer or tagger line with fields after its time zone, such as `A 1112591163 +0200 1700000000 -0500`, decodes through `Commit.author()`/`committer()` and `Tag.tagger()` with the correct seconds but an offset of 0. Git reads only the first date and time zone after the last `>` (`split_ident_line`) and reports `1112591163 +0200` through `git show --format=%ad --date=raw` and `%(taggerdate:raw)`. `git fsck` reports `badTimezone` for such objects, but the default `transfer.fsckObjects=false` accepts them. The `trailing-date` read-only `commit.sh` fixture in `test_deserialization_reads_date_after_email` reproduces both object kinds. | Report the time zone Git reads for identity lines with trailing fields, or reject them consistently. `object_metadata()` selects CLI when the decoded signature does not reproduce the end of its raw header line. This adapter guard does not close the upstream compatibility bug. | The PR #2306 regression exposed an adapter ownership-check bypass in both backends: `Repo`'s automatic `GIT_DIR` made Git 2.54.0 treat hook execution as diff --git a/git/_backend.py b/git/_backend.py index 82c46b8b8..8702ef0d0 100644 --- a/git/_backend.py +++ b/git/_backend.py @@ -239,6 +239,15 @@ def object_metadata(command: Any, oid: str, kind: str) -> Any: "signature": data.signature, "tagger": obj.tagger(), } + signatures = ( + ((data.author, result["author"]), (data.committer, result["committer"])) + if kind == "commit" + else ((data.tagger, result["tagger"]),) + ) + for raw, signature in signatures: + # Gix reads a time zone of 0 when other fields follow the date (GIX-34). + if signature is not None and not raw.endswith(b"> " + _date(signature)): + raise _Unsupported("signature trailing fields (GIX-34)") except _Unsupported as exc: return _fallback(kind + ".decode", str(exc)) except gix.Error as exc: diff --git a/git/objects/util.py b/git/objects/util.py index f648a2509..96d8e3191 100644 --- a/git/objects/util.py +++ b/git/objects/util.py @@ -266,6 +266,9 @@ def parse_date(string_date: Union[str, datetime]) -> Tuple[int, int]: # Check the line ending once, before parsing fields, to avoid repeated backtracking. # Keep the field name from consuming spaces belonging to the actor. _re_actor_epoch = re.compile(r"^(?=[^\n]*$).[^ \n]* (.*) (\d+) ([+-]\d+)") +# Like Git's split_ident_line, take the date only from right after the last '>'. +_re_actor_mail = re.compile(r"^(?=[^\n]*$).[^ \n]* (.*>)(.*)$") +_re_epoch = re.compile(r"[ \t\r]*(\d+)[ \t\r]*([+-]\d+)") _re_only_actor = re.compile(r"^.[^ \n]* (.*)$") @@ -278,12 +281,19 @@ def parse_actor_and_date(line: str) -> Tuple[Actor, int, int]: [Actor, int_seconds_since_epoch, int_timezone_offset] """ actor, epoch, offset = "", "0", "0" - m = _re_actor_epoch.search(line) + m = _re_actor_mail.search(line) if m: - actor, epoch, offset = m.groups() + actor, rest = m.groups() + m = _re_epoch.match(rest) + if m: + epoch, offset = m.groups() else: - m = _re_only_actor.search(line) - actor = m.group(1) if m else line or "" + m = _re_actor_epoch.search(line) + if m: + actor, epoch, offset = m.groups() + else: + m = _re_only_actor.search(line) + actor = m.group(1) if m else line or "" return (Actor._from_string(actor), int(epoch), utctz_to_altz(offset)) diff --git a/test/test_commit.py b/test/test_commit.py index 06a0ff60c..20da8f197 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -14,7 +14,7 @@ from gitdb import IStream -from git import Actor, Commit, Repo, _backend +from git import Actor, Commit, Repo, TagObject, _backend from git.compat import safe_decode, safe_encode from git.exc import UnsafeOptionError from git.objects.util import tzoffset, utc @@ -646,6 +646,31 @@ def test_deserialization_headers_end_at_empty_line(self): key = ("commit.decode", "CLI: " + reason) self.assertEqual(_backend.statistics().get(key, 0), before.get(key, 0) + 1) + def test_deserialization_reads_date_after_email(self): + """Only the date right after the email counts, as in Git's split_ident_line.""" + with fixture_repo("commit.sh", "trailing-date") as repo: + root = Path(repo.working_dir).parent + oid = (root / "object-id").read_text().strip() + tag_oid = (root / "tag-id").read_text().strip() + expected = repo.git.show("--no-patch", "--format=%ad%n%cd", "--date=raw", oid, "--") + self.assertEqual(expected, "1112591163 +0200\n1112591163 +0200") + self.assertTrue(repo.git.cat_file("-p", tag_oid).split("\n")[3].endswith(" +0200 1700000000 -0500")) + self.assertEqual(repo.git.for_each_ref("--format=%(taggerdate:raw)", "refs/tags/v1"), "1112591163 +0200") + raw = repo.git.cat_file("commit", oid, stdout_as_string=False, strip_newline_in_stdout=False) + before = _backend.statistics() + for cmt in (Commit(repo, bytes.fromhex(oid))._deserialize(BytesIO(raw)), Commit(repo, bytes.fromhex(oid))): + self.assertEqual((cmt.authored_date, cmt.author_tz_offset), (1112591163, -7200)) + self.assertEqual((cmt.committed_date, cmt.committer_tz_offset), (1112591163, -7200)) + self.assertEqual((cmt.author.email, cmt.committer.email), ("a@example.com", "c@example.com")) + tag = TagObject(repo, bytes.fromhex(tag_oid)) + self.assertEqual( + (tag.tagged_date, tag.tagger_tz_offset, tag.tagger.email), (1112591163, -7200, "t@example.com") + ) + if _backend.name == "gix": + for kind in ("commit", "tag"): + key = (kind + ".decode", "CLI: signature trailing fields (GIX-34)") + self.assertEqual(_backend.statistics().get(key, 0), before.get(key, 0) + 1) + def test_identity_cannot_alter_headers(self): """A name or email must not add header lines or present another identity.""" with fixture_repo("commit.sh", "tree", write=True) as rw_repo: diff --git a/test/test_util.py b/test/test_util.py index ab233feeb..bba4b7572 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -646,7 +646,11 @@ def test_actor_from_string(self): ("committer Name 42 +0530\n", Actor("Name", "email"), 42, -19800), ("tagger Name 42 +0000\r\n", Actor("Name", "email"), 42, 0), ("author Name 42 -0700 trailing", Actor("Name", "email"), 42, 25200), - ("author Name 1 +0 42 -0700", Actor("Name", "email"), 42, 25200), + ("author Name 1 +0 42 -0700", Actor("Name", "email"), 1, 0), + ("author Name 42-0700", Actor("Name", "email"), 42, 25200), + ("author Name junk 42 -0700", Actor("Name", "email"), 0, 0), + ("author Name 42 -0700", Actor("Name", "email"), 42, 25200), + ("author Name 42 -0700 ", Actor("Name", "email"), 0, 0), ("author Name invalid -0700", Actor("Name", "email"), 0, 0), ("author Name 42 invalid", Actor("Name", "email"), 0, 0), ("author 42 -0700", Actor("42 -0700", None), 0, 0), diff --git a/tests/fixtures/commit.sh b/tests/fixtures/commit.sh index be992556e..8b1c55ee8 100644 --- a/tests/fixtures/commit.sh +++ b/tests/fixtures/commit.sh @@ -47,6 +47,21 @@ case "$1" in raw) git hash-object --literally -w -t commit "$2" > ../object-id ;; + trailing-date) + tree=$(git mktree 1112591163 +0200 1700000000 -0500\n' + printf 'committer C 1112591163 +0200 1700000000 -0500\n' + printf '\nmessage\n' + } | git hash-object --literally -w -t commit --stdin > ../object-id + { + printf 'object %s\ntype commit\ntag v1\n' "$(cat ../object-id)" + printf 'tagger T 1112591163 +0200 1700000000 -0500\n' + printf '\nmessage\n' + } | git hash-object --literally -w -t tag --stdin > ../tag-id + git update-ref refs/tags/v1 "$(cat ../tag-id)" + ;; whitespace-headers) tree=$(git mktree