Skip to content

Commit e694e9b

Browse files
committed
fix(objects): read identity dates right after the email like git
<!-- agent --> `parse_actor_and_date()` matched `author`, `committer` and `tagger` lines with `(.*) (\d+) ([+-]\d+)`, so the greedy actor group made it take the *last* `<digits> <+/-digits>` pair on the line. Git's `split_ident_line()` reads the date only from right after the last `>` and ignores whatever follows the time zone. Whoever authors a commit or tag controls these lines, so an object with author A <a@example.com> 1112591163 +0200 1700000000 -0500 is shown by `git log`, `git show --date=raw` and `for-each-ref %(taggerdate:raw)` as `1112591163 +0200`, while `Commit.authored_date`/`author_tz_offset` (and the committer and tagger equivalents) reported `1700000000` and `-0500` on the CLI backend. The name and email agree, so only the dates silently differ. `git fsck` reports `badTimezone`, but the default `transfer.fsckObjects=false` accepts such objects on clone and fetch. A tool that uses GitPython's dates for audit timelines, release windows or "commits since" filters was shown dates of the author's choosing. For lines containing `>`, take the actor up to the last `>` and parse only a leading date and time zone from the remainder, allowing the spaces, tabs and carriage returns Git skips; anything else after it means no date, as in Git. Lines without `>` keep the previous pattern so email-less identities and the existing long-line timing guarantees are unchanged. The `1 +0 42 -0700` case in `test_parse_actor_and_date` encoded the old last-pair reading and now expects Git's `1 +0000`. The Gix backend shares this parser when native decoding fails. When GixPython 0.3.0 does decode such a line, `Commit.author()`/`committer()` and `Tag.tagger()` return the right seconds but an offset of 0, so `object_metadata()` now selects the CLI path when a decoded signature does not reproduce the end of its raw header line. The mismatch is recorded as GIX-34 in `doc/gix-backend.md` and stays open upstream. Validation: the new `trailing-date` fixture in `tests/fixtures/commit.sh` backs `test_deserialization_reads_date_after_email`, which checks a commit and a tag against Git's raw date output and the GIX-34 fallback counter. It and four `test_parse_actor_and_date` cases fail before the change and pass after it on both backends. The new parser expectations were compared with `git log --format=%ad --date=raw` for seven identity-line variants. `ruff check`, `ruff format --check` and `shellcheck` pass on the touched files.
1 parent ad0420e commit e694e9b

6 files changed

Lines changed: 70 additions & 6 deletions

File tree

‎doc/gix-backend.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,7 @@ identify the historical fixture where needed.
966966
| 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. |
967967
| 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. |
968968
| 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. |
969+
| 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 <a@example.com> 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. |
969970

970971
The PR #2306 regression exposed an adapter ownership-check bypass in both
971972
backends: `Repo`'s automatic `GIT_DIR` made Git 2.54.0 treat hook execution as

‎git/_backend.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ def object_metadata(command: Any, oid: str, kind: str) -> Any:
239239
"signature": data.signature,
240240
"tagger": obj.tagger(),
241241
}
242+
signatures = (
243+
((data.author, result["author"]), (data.committer, result["committer"]))
244+
if kind == "commit"
245+
else ((data.tagger, result["tagger"]),)
246+
)
247+
for raw, signature in signatures:
248+
# Gix reads a time zone of 0 when other fields follow the date (GIX-34).
249+
if signature is not None and not raw.endswith(b"> " + _date(signature)):
250+
raise _Unsupported("signature trailing fields (GIX-34)")
242251
except _Unsupported as exc:
243252
return _fallback(kind + ".decode", str(exc))
244253
except gix.Error as exc:

‎git/objects/util.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ def parse_date(string_date: Union[str, datetime]) -> Tuple[int, int]:
266266
# Check the line ending once, before parsing fields, to avoid repeated backtracking.
267267
# Keep the field name from consuming spaces belonging to the actor.
268268
_re_actor_epoch = re.compile(r"^(?=[^\n]*$).[^ \n]* (.*) (\d+) ([+-]\d+)")
269+
# Like Git's split_ident_line, take the date only from right after the last '>'.
270+
_re_actor_mail = re.compile(r"^(?=[^\n]*$).[^ \n]* (.*>)(.*)$")
271+
_re_epoch = re.compile(r"[ \t\r]*(\d+)[ \t\r]*([+-]\d+)")
269272
_re_only_actor = re.compile(r"^.[^ \n]* (.*)$")
270273

271274

@@ -278,12 +281,19 @@ def parse_actor_and_date(line: str) -> Tuple[Actor, int, int]:
278281
[Actor, int_seconds_since_epoch, int_timezone_offset]
279282
"""
280283
actor, epoch, offset = "", "0", "0"
281-
m = _re_actor_epoch.search(line)
284+
m = _re_actor_mail.search(line)
282285
if m:
283-
actor, epoch, offset = m.groups()
286+
actor, rest = m.groups()
287+
m = _re_epoch.match(rest)
288+
if m:
289+
epoch, offset = m.groups()
284290
else:
285-
m = _re_only_actor.search(line)
286-
actor = m.group(1) if m else line or ""
291+
m = _re_actor_epoch.search(line)
292+
if m:
293+
actor, epoch, offset = m.groups()
294+
else:
295+
m = _re_only_actor.search(line)
296+
actor = m.group(1) if m else line or ""
287297
return (Actor._from_string(actor), int(epoch), utctz_to_altz(offset))
288298

289299

‎test/test_commit.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from gitdb import IStream
1616

17-
from git import Actor, Commit, Repo, _backend
17+
from git import Actor, Commit, Repo, TagObject, _backend
1818
from git.compat import safe_decode, safe_encode
1919
from git.exc import UnsafeOptionError
2020
from git.objects.util import tzoffset, utc
@@ -646,6 +646,31 @@ def test_deserialization_headers_end_at_empty_line(self):
646646
key = ("commit.decode", "CLI: " + reason)
647647
self.assertEqual(_backend.statistics().get(key, 0), before.get(key, 0) + 1)
648648

649+
def test_deserialization_reads_date_after_email(self):
650+
"""Only the date right after the email counts, as in Git's split_ident_line."""
651+
with fixture_repo("commit.sh", "trailing-date") as repo:
652+
root = Path(repo.working_dir).parent
653+
oid = (root / "object-id").read_text().strip()
654+
tag_oid = (root / "tag-id").read_text().strip()
655+
expected = repo.git.show("--no-patch", "--format=%ad%n%cd", "--date=raw", oid, "--")
656+
self.assertEqual(expected, "1112591163 +0200\n1112591163 +0200")
657+
self.assertTrue(repo.git.cat_file("-p", tag_oid).split("\n")[3].endswith(" +0200 1700000000 -0500"))
658+
self.assertEqual(repo.git.for_each_ref("--format=%(taggerdate:raw)", "refs/tags/v1"), "1112591163 +0200")
659+
raw = repo.git.cat_file("commit", oid, stdout_as_string=False, strip_newline_in_stdout=False)
660+
before = _backend.statistics()
661+
for cmt in (Commit(repo, bytes.fromhex(oid))._deserialize(BytesIO(raw)), Commit(repo, bytes.fromhex(oid))):
662+
self.assertEqual((cmt.authored_date, cmt.author_tz_offset), (1112591163, -7200))
663+
self.assertEqual((cmt.committed_date, cmt.committer_tz_offset), (1112591163, -7200))
664+
self.assertEqual((cmt.author.email, cmt.committer.email), ("a@example.com", "c@example.com"))
665+
tag = TagObject(repo, bytes.fromhex(tag_oid))
666+
self.assertEqual(
667+
(tag.tagged_date, tag.tagger_tz_offset, tag.tagger.email), (1112591163, -7200, "t@example.com")
668+
)
669+
if _backend.name == "gix":
670+
for kind in ("commit", "tag"):
671+
key = (kind + ".decode", "CLI: signature trailing fields (GIX-34)")
672+
self.assertEqual(_backend.statistics().get(key, 0), before.get(key, 0) + 1)
673+
649674
def test_identity_cannot_alter_headers(self):
650675
"""A name or email must not add header lines or present another identity."""
651676
with fixture_repo("commit.sh", "tree", write=True) as rw_repo:

‎test/test_util.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,11 @@ def test_actor_from_string(self):
646646
("committer Name <email> 42 +0530\n", Actor("Name", "email"), 42, -19800),
647647
("tagger Name <email> 42 +0000\r\n", Actor("Name", "email"), 42, 0),
648648
("author Name <email> 42 -0700 trailing", Actor("Name", "email"), 42, 25200),
649-
("author Name <email> 1 +0 42 -0700", Actor("Name", "email"), 42, 25200),
649+
("author Name <email> 1 +0 42 -0700", Actor("Name", "email"), 1, 0),
650+
("author Name <email>42-0700", Actor("Name", "email"), 42, 25200),
651+
("author Name <email> junk 42 -0700", Actor("Name", "email"), 0, 0),
652+
("author Name <email> <x> 42 -0700", Actor("Name", "email"), 42, 25200),
653+
("author Name <email> 42 -0700 <x>", Actor("Name", "email"), 0, 0),
650654
("author Name <email> invalid -0700", Actor("Name", "email"), 0, 0),
651655
("author Name <email> 42 invalid", Actor("Name", "email"), 0, 0),
652656
("author 42 -0700", Actor("42 -0700", None), 0, 0),

‎tests/fixtures/commit.sh‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ case "$1" in
4747
raw)
4848
git hash-object --literally -w -t commit "$2" > ../object-id
4949
;;
50+
trailing-date)
51+
tree=$(git mktree </dev/null)
52+
{
53+
printf 'tree %s\n' "$tree"
54+
printf 'author A <a@example.com> 1112591163 +0200 1700000000 -0500\n'
55+
printf 'committer C <c@example.com> 1112591163 +0200 1700000000 -0500\n'
56+
printf '\nmessage\n'
57+
} | git hash-object --literally -w -t commit --stdin > ../object-id
58+
{
59+
printf 'object %s\ntype commit\ntag v1\n' "$(cat ../object-id)"
60+
printf 'tagger T <t@example.com> 1112591163 +0200 1700000000 -0500\n'
61+
printf '\nmessage\n'
62+
} | git hash-object --literally -w -t tag --stdin > ../tag-id
63+
git update-ref refs/tags/v1 "$(cat ../tag-id)"
64+
;;
5065
whitespace-headers)
5166
tree=$(git mktree </dev/null)
5267
{

0 commit comments

Comments
 (0)