Commit Diff


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: <unix timestamp> <timezone offset>
+        - 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 <joe@example.com>",
             committer="Bob <bob@example.com>",
             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 <joe@example.com>",
+            author_timezone=18000,
+            committer="Bob <bob@example.com>",
+            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 <joe@example.com>",
+            committer="Bob <bob@example.com>",
+        )
+        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 <joe@example.com>",
+            committer="Bob <bob@example.com>",
         )
         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"""