Skip to content

Commit b84749b

Browse files
committed
fix(repo): preserve timezone metadata in blame results
During a review of Git metadata APIs for development tools, I found that blame results for working-tree changes could not be read through the ordinary datetime properties. Both parsers already retain the actor identities and timestamps, but omit the timezone fields. This makes `authored_datetime` and `committed_datetime` try to load the zero object for uncommitted lines, raising `BadObject`. Parse the offsets reported by Git with the existing `utctz_to_altz` helper and provide them to `Commit`. The ordinary parser preserves its optional-field fallback. This is shared porcelain parsing; blame remains a Git CLI fallback in Gix mode. The shell fixture creates a fresh repository with different author and committer offsets and an uncommitted line. Its four subcases fail on the original source and pass with this change. On Windows CPython3.13.5, the complete non-network `test_repo.py` and `test_commit.py` suites each pass with175 tests and68 subtests under both CLI and Gix selections, with8 skips and1 expected failure per run. Ruff, mypy, basedpyright, ShellCheck and applicable file checks pass. Independent review covers six additional API combinations and24 entries. The full repository, network, other platform/interpreter and documentation matrices were not run. Assisted-by: OpenAI Codex Agent-Model: GPT-6
1 parent 23f33ef commit b84749b

3 files changed

Lines changed: 46 additions & 1 deletion

File tree

‎git/repo/base.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
)
3636
from git.index import IndexFile
3737
from git.objects import Submodule, RootModule, Commit
38+
from git.objects.util import utctz_to_altz
3839
from git.refs import HEAD, Head, Reference, SymbolicReference, TagReference
3940
from git.remote import Remote, _T_RemoteName, add_progress, to_progress_instance
4041
from git.util import (
@@ -1363,11 +1364,13 @@ def blame_incremental(
13631364
safe_decode(props[b"author-mail"].lstrip(b"<").rstrip(b">")),
13641365
),
13651366
authored_date=int(props[b"author-time"]),
1367+
author_tz_offset=utctz_to_altz(props[b"author-tz"].decode("ascii")),
13661368
committer=Actor(
13671369
safe_decode(props[b"committer"]),
13681370
safe_decode(props[b"committer-mail"].lstrip(b"<").rstrip(b">")),
13691371
),
13701372
committed_date=int(props[b"committer-time"]),
1373+
committer_tz_offset=utctz_to_altz(props[b"committer-tz"].decode("ascii")),
13711374
)
13721375
commits[hexsha] = c
13731376
else:
@@ -1452,9 +1455,11 @@ class InfoTD(TypedDict, total=False):
14521455
author: str
14531456
author_email: str
14541457
author_date: int
1458+
author_tz_offset: int
14551459
committer: str
14561460
committer_email: str
14571461
committer_date: int
1462+
committer_tz_offset: int
14581463

14591464
info: InfoTD = {}
14601465

@@ -1504,20 +1509,24 @@ class InfoTD(TypedDict, total=False):
15041509
# committer Tom Preston-Werner
15051510
# committer-mail <tom@mojombo.com>
15061511
# committer-time 1192271832
1507-
# committer-tz -0700 - IGNORED BY US
1512+
# committer-tz -0700
15081513
role = m.group(0)
15091514
if role == "author":
15101515
if firstpart.endswith("-mail"):
15111516
info["author_email"] = parts[-1]
15121517
elif firstpart.endswith("-time"):
15131518
info["author_date"] = int(parts[-1])
1519+
elif firstpart.endswith("-tz"):
1520+
info["author_tz_offset"] = utctz_to_altz(parts[-1])
15141521
elif role == firstpart:
15151522
info["author"] = parts[-1]
15161523
elif role == "committer":
15171524
if firstpart.endswith("-mail"):
15181525
info["committer_email"] = parts[-1]
15191526
elif firstpart.endswith("-time"):
15201527
info["committer_date"] = int(parts[-1])
1528+
elif firstpart.endswith("-tz"):
1529+
info["committer_tz_offset"] = utctz_to_altz(parts[-1])
15211530
elif role == firstpart:
15221531
info["committer"] = parts[-1]
15231532
# END distinguish mail,time,name
@@ -1540,8 +1549,10 @@ class InfoTD(TypedDict, total=False):
15401549
hex_to_bin(sha),
15411550
author=Actor._from_string(f"{info['author']} {info['author_email']}"),
15421551
authored_date=info["author_date"],
1552+
author_tz_offset=info.get("author_tz_offset"),
15431553
committer=Actor._from_string(f"{info['committer']} {info['committer_email']}"),
15441554
committed_date=info["committer_date"],
1555+
committer_tz_offset=info.get("committer_tz_offset"),
15451556
)
15461557
commits[sha] = c
15471558
blames[-1][0] = c

‎test/test_repo.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,30 @@ def test_blame_preserves_line_content(self):
773773
lines = [line for _commit, blamed in repo.blame("HEAD", "file.txt") for line in blamed]
774774
self.assertEqual(lines, ["alpha ", "beta", "\t", "crlf\r", "gamma"])
775775

776+
def test_blame_preserves_timezones(self):
777+
with fixture_repo("blame-timezones.sh") as repo:
778+
for incremental in (False, True):
779+
for rev in ("HEAD", None):
780+
with self.subTest(incremental=incremental, rev=rev):
781+
entry = next(iter(repo.blame(rev, "file.txt", incremental=incremental)))
782+
commit = entry[0]
783+
porcelain = repo.git.blame(rev, "--line-porcelain", "--", "file.txt")
784+
offsets = dict(
785+
line.split(" ", 1)
786+
for line in porcelain.splitlines()
787+
if line.startswith(("author-tz ", "committer-tz "))
788+
)
789+
with mock.patch.object(
790+
Commit, "_set_cache_", side_effect=AssertionError("unexpected object read")
791+
):
792+
if rev is None:
793+
self.assertEqual(commit.hexsha, "0" * 40)
794+
else:
795+
self.assertEqual(commit.author_tz_offset, -19800)
796+
self.assertEqual(commit.committer_tz_offset, 14400)
797+
self.assertEqual(commit.authored_datetime.strftime("%z"), offsets["author-tz"])
798+
self.assertEqual(commit.committed_datetime.strftime("%z"), offsets["committer-tz"])
799+
776800
@mock.patch.object(Git, "_call_process")
777801
def test_blame_complex_revision(self, git):
778802
git.return_value = fixture("blame_complex_revision")

‎tests/fixtures/blame-timezones.sh‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
git init -q --initial-branch=main repo
4+
cd repo
5+
printf 'committed\n' > file.txt
6+
git add file.txt
7+
GIT_AUTHOR_DATE='2020-01-01T12:00:00+0530' \
8+
GIT_COMMITTER_DATE='2020-01-01T13:00:00-0400' \
9+
git commit -q -m initial
10+
printf 'changed\n' > file.txt

0 commit comments

Comments
 (0)