commit f0daf70250f11e3a337bbb4f768b9fc6683d3a23 from: springheeledjack0 <112511761+springheeledjack0@users.noreply.github.com> via: GitHub date: Sun Sep 04 12:43:32 2022 UTC Implement timezone offset detection (#1026) commit - f8437ed2129092cf41e89e26510c51576b271aa4 commit + f0daf70250f11e3a337bbb4f768b9fc6683d3a23 blob - 5bf2112bfdfb23fb71b880f3a18c79f389ebe232 blob + 7136bdbcf995cac7d0671d400dac3162cf53a18a --- dulwich/porcelain.py +++ dulwich/porcelain.py @@ -189,8 +189,80 @@ class Error(Exception): class RemoteExists(Error): """Raised when the remote already exists.""" + + +class TimezoneFormatError(Error): + """Raised when the timezone cannot be determined from a given string.""" + + +def parse_timezone_format(tz_str): + """Parse given string and attempt to return a timezone offset. + Different formats are considered in the following order: + - Git internal format: + - RFC 2822: e.g. Mon, 20 Nov 1995 19:12:08 -0500 + - ISO 8601: e.g. 1995-11-20T19:12:08-0500 + Args: + tz_str: datetime string + Returns: Timezone offset as integer + Raises: + TimezoneFormatError: if timezone information cannot be extracted + """ + import re + + # Git internal format + internal_format_pattern = re.compile("^[0-9]+ [+-][0-9]{,4}$") + if re.match(internal_format_pattern, tz_str): + try: + tz_internal = parse_timezone(tz_str.split(" ")[1].encode(DEFAULT_ENCODING)) + return tz_internal[0] + except ValueError: + pass + + # RFC 2822 + import email.utils + rfc_2822 = email.utils.parsedate_tz(tz_str) + if rfc_2822: + return rfc_2822[9] + + # ISO 8601 + + # Supported offsets: + # sHHMM, sHH:MM, sHH + iso_8601_pattern = re.compile("[0-9] ?([+-])([0-9]{2})(?::(?=[0-9]{2}))?([0-9]{2})?$") + match = re.search(iso_8601_pattern, tz_str) + total_secs = 0 + if match: + sign, hours, minutes = match.groups() + total_secs += int(hours) * 3600 + if minutes: + total_secs += int(minutes) * 60 + total_secs = -total_secs if sign == "-" else total_secs + return total_secs + + # YYYY.MM.DD, MM/DD/YYYY, DD.MM.YYYY contain no timezone information + + raise TimezoneFormatError(tz_str) + + +def get_user_timezones(): + """Retrieve local timezone as described in + https://raw.githubusercontent.com/git/git/v2.3.0/Documentation/date-formats.txt + Returns: A tuple containing author timezone, committer timezone + """ + local_timezone = time.localtime().tm_gmtoff + + if os.environ.get("GIT_AUTHOR_DATE"): + author_timezone = parse_timezone_format(os.environ["GIT_AUTHOR_DATE"]) + else: + author_timezone = local_timezone + if os.environ.get("GIT_COMMITTER_DATE"): + commit_timezone = parse_timezone_format(os.environ["GIT_COMMITTER_DATE"]) + else: + commit_timezone = local_timezone + return author_timezone, commit_timezone + def open_repo(path_or_repo): """Open an argument that can be a repository or a path for a repository.""" if isinstance(path_or_repo, BaseRepo): @@ -329,7 +401,9 @@ def commit( repo=".", message=None, author=None, + author_timezone=None, committer=None, + commit_timezone=None, encoding=None, no_verify=False, signoff=False, @@ -340,7 +414,9 @@ def commit( repo: Path to repository message: Optional commit message author: Optional author name and email + author_timezone: Author timestamp timezone committer: Optional committer name and email + commit_timezone: Commit timestamp timezone no_verify: Skip pre-commit and commit-msg hooks signoff: GPG Sign the commit (bool, defaults to False, pass True to use default GPG key, @@ -354,11 +430,18 @@ def commit( author = author.encode(encoding or DEFAULT_ENCODING) if getattr(committer, "encode", None): committer = committer.encode(encoding or DEFAULT_ENCODING) + local_timezone = get_user_timezones() + if author_timezone is None: + author_timezone = local_timezone[0] + if commit_timezone is None: + commit_timezone = local_timezone[1] with open_repo_closing(repo) as r: return r.do_commit( message=message, author=author, + author_timezone=author_timezone, committer=committer, + commit_timezone=commit_timezone, encoding=encoding, no_verify=no_verify, sign=signoff if isinstance(signoff, (str, bool)) else None, @@ -959,8 +1042,7 @@ def tag_create( tag_time = int(time.time()) tag_obj.tag_time = tag_time if tag_timezone is None: - # TODO(jelmer) Use current user timezone rather than UTC - tag_timezone = 0 + tag_timezone = get_user_timezones()[1] elif isinstance(tag_timezone, str): tag_timezone = parse_timezone(tag_timezone) tag_obj.tag_timezone = tag_timezone blob - 19420dd6c32c51f52bc16824822cf29326369fc0 blob + c19cf0ed1f69cc6e61b11467e2c5bfd9ca157737 --- dulwich/tests/test_porcelain.py +++ dulwich/tests/test_porcelain.py @@ -417,11 +417,63 @@ class CommitTests(PorcelainTestCase): author="Joe ", committer="Bob ", no_verify=True, + ) + self.assertIsInstance(sha, bytes) + self.assertEqual(len(sha), 40) + + def test_timezone(self): + c1, c2, c3 = build_commit_graph( + self.repo.object_store, [[1], [2, 1], [3, 1, 2]] + ) + self.repo.refs[b"refs/heads/foo"] = c3.id + sha = porcelain.commit( + self.repo.path, + message="Some message", + author="Joe ", + author_timezone=18000, + committer="Bob ", + commit_timezone=18000, + ) + self.assertIsInstance(sha, bytes) + self.assertEqual(len(sha), 40) + + commit = self.repo.get_object(sha) + self.assertEqual(commit._author_timezone, 18000) + self.assertEqual(commit._commit_timezone, 18000) + + os.environ["GIT_AUTHOR_DATE"] = os.environ["GIT_COMMITTER_DATE"] = "1995-11-20T19:12:08-0501" + + sha = porcelain.commit( + self.repo.path, + message="Some message", + author="Joe ", + committer="Bob ", + ) + self.assertIsInstance(sha, bytes) + self.assertEqual(len(sha), 40) + + commit = self.repo.get_object(sha) + self.assertEqual(commit._author_timezone, -18060) + self.assertEqual(commit._commit_timezone, -18060) + + del os.environ["GIT_AUTHOR_DATE"] + del os.environ["GIT_COMMITTER_DATE"] + local_timezone = time.localtime().tm_gmtoff + + sha = porcelain.commit( + self.repo.path, + message="Some message", + author="Joe ", + committer="Bob ", ) self.assertIsInstance(sha, bytes) self.assertEqual(len(sha), 40) + commit = self.repo.get_object(sha) + self.assertEqual(commit._author_timezone, local_timezone) + self.assertEqual(commit._commit_timezone, local_timezone) + @skipIf(platform.python_implementation() == "PyPy" or sys.platform == "win32", "gpgme not easily available or supported on Windows and PyPy") class CommitSignTests(PorcelainGpgTestCase): @@ -486,6 +538,79 @@ class CommitSignTests(PorcelainGpgTestCase): commit.verify() +class TimezoneTests(PorcelainTestCase): + + def put_envs(self, value): + os.environ["GIT_AUTHOR_DATE"] = os.environ["GIT_COMMITTER_DATE"] = value + + def fallback(self, value): + self.put_envs(value) + self.assertRaises(porcelain.TimezoneFormatError, porcelain.get_user_timezones) + + def test_internal_format(self): + self.put_envs("0 +0500") + self.assertTupleEqual((18000, 18000), porcelain.get_user_timezones()) + + def test_rfc_2822(self): + self.put_envs("Mon, 20 Nov 1995 19:12:08 -0500") + self.assertTupleEqual((-18000, -18000), porcelain.get_user_timezones()) + + self.put_envs("Mon, 20 Nov 1995 19:12:08") + self.assertTupleEqual((0, 0), porcelain.get_user_timezones()) + + def test_iso8601(self): + self.put_envs("1995-11-20T19:12:08-0501") + self.assertTupleEqual((-18060, -18060), porcelain.get_user_timezones()) + + self.put_envs("1995-11-20T19:12:08+0501") + self.assertTupleEqual((18060, 18060), porcelain.get_user_timezones()) + + self.put_envs("1995-11-20T19:12:08-05:01") + self.assertTupleEqual((-18060, -18060), porcelain.get_user_timezones()) + + self.put_envs("1995-11-20 19:12:08-05") + self.assertTupleEqual((-18000, -18000), porcelain.get_user_timezones()) + + # https://github.com/git/git/blob/96b2d4fa927c5055adc5b1d08f10a5d7352e2989/t/t6300-for-each-ref.sh#L128 + self.put_envs("2006-07-03 17:18:44 +0200") + self.assertTupleEqual((7200, 7200), porcelain.get_user_timezones()) + + def test_missing_or_malformed(self): + # TODO: add more here + self.fallback("0 + 0500") + self.fallback("a +0500") + + self.fallback("1995-11-20T19:12:08") + self.fallback("1995-11-20T19:12:08-05:") + + self.fallback("1995.11.20") + self.fallback("11/20/1995") + self.fallback("20.11.1995") + + def test_different_envs(self): + os.environ["GIT_AUTHOR_DATE"] = "0 +0500" + os.environ["GIT_COMMITTER_DATE"] = "0 +0501" + self.assertTupleEqual((18000, 18060), porcelain.get_user_timezones()) + + def test_no_envs(self): + local_timezone = time.localtime().tm_gmtoff + + self.put_envs("0 +0500") + self.assertTupleEqual((18000, 18000), porcelain.get_user_timezones()) + + del os.environ["GIT_COMMITTER_DATE"] + self.assertTupleEqual((18000, local_timezone), porcelain.get_user_timezones()) + + self.put_envs("0 +0500") + del os.environ["GIT_AUTHOR_DATE"] + self.assertTupleEqual((local_timezone, 18000), porcelain.get_user_timezones()) + + self.put_envs("0 +0500") + del os.environ["GIT_AUTHOR_DATE"] + del os.environ["GIT_COMMITTER_DATE"] + self.assertTupleEqual((local_timezone, local_timezone), porcelain.get_user_timezones()) + + class CleanTests(PorcelainTestCase): def put_files(self, tracked, ignored, untracked, empty_dirs): """Put the described files in the wd"""