Repository: jelmer/dulwich

Issue:
porcelain.tag_create fails for annotated tag with a message
Hi, thanks for writing this great library! I am trying to use it to add some tags to my repo, but `porcelain.tag_create` seems to fail when I try to create an annotated tag with a message.

Reproduction:
```
from dulwich import porcelain

porcelain.tag_create(".", "1.2.3", annotated=True, message="Some tagging message")
```
Error:
```
Traceback (most recent call last):
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/tag_create_bug.py", line 3, in <module>
    porcelain.tag_create(".", "1.2.3", annotated=True, message="Some tagging message")
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/porcelain.py", line 1074, in tag_create
    tag_obj.message = message + "\n".encode(encoding)
                      ~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~
TypeError: can only concatenate str (not "bytes") to str
```
I am using: 
python.     3.12.2
dulwich     0.22.7

Also, I was reading your docs and I noticed some tagging code here: https://www.dulwich.io/docs/tutorial/tag.html
I could not use it directly, so instead I tried using a simplified impl based on `tag_create`:
```
from dulwich import porcelain
from dulwich.repo import get_user_identity
from dulwich.objects import Tag
import time

def tag_repo(gitlab_project_dir: str, tag:str, tag_message:str) -> None:
    # porcelain.tag_create(gitlab_project_dir, tag, annotated=True, message="foo")
    with porcelain.open_repo_closing(gitlab_project_dir) as r:
        object = porcelain.parse_object(r, "HEAD")

        tag_obj = Tag()
        tag_obj.tagger = get_user_identity(r.get_config_stack())
        tag_obj.message = tag_message
        tag_obj.name = tag
        tag_obj.object = (type(object), object.id)
        tag_obj.tag_time = int(time.time())
        tag_obj.tag_timezone = porcelain.get_user_timezones()[1]

        r.object_store.add_object(tag_obj)
        tag_id = tag_obj.id
        r.refs['refs/tags/' + tag] = tag_id

# porcelain.tag_create(".", "1.2.3", annotated=True, message="Some tagging message")
tag_repo(".", "1.2.3", "Some tagging message")
```
But I get a similar looking encoding issue but in `objects.py`
```
Traceback (most recent call last):
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/tag_create_bug.py", line 24, in <module>
    tag_repo(".", "1.2.3", "Some tagging message")
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/tag_create_bug.py", line 19, in tag_repo
    r.object_store.add_object(tag_obj)
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/object_store.py", line 1011, in add_object
    path = self._get_shafile_path(obj.id)
                                  ^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 560, in id
    return self.sha().hexdigest().encode("ascii")
           ^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 544, in sha
    new_sha.update(self._header())
                   ^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 533, in _header
    return object_header(self.type_num, self.raw_length())
                                        ^^^^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 537, in raw_length
    return sum(map(len, self.as_raw_chunks()))
                        ^^^^^^^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 343, in as_raw_chunks
    self._chunked_text = self._serialize()
                         ^^^^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 832, in _serialize
    body = (self.message or b"") + (self._signature or b"")
           ~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~
TypeError: can only concatenate str (not "bytes") to str
```

And if I encode the tag_message, then it complains here that it is not a string:
```
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 730, in _format_message
    lines = value.split(b"\n")
            ^^^^^^^^^^^^^^^^^^
TypeError: must be str or None, not bytes
```

Discussion:
@jelmer: The message is expected to be a bytestring today, so if you just pass in ``message.encode('utf-8')`` this will probably work.

That said, dulwich should ideally also do this for you if you pass in a plain string - so let's leave this issue open for it to do that.@jennerm: Hi @jelmer, thanks for responding so quickly.

I did in fact try exactly what you suggested above, but sadly that fails too, a different bit of code expects it to be a string it seems:
```
porcelain.tag_create(".", "1.2.3", annotated=True, message="Some tagging message".encode("utf-8"))
```
Error:
```
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/tag_create_bug.py", line 23, in <module>
    porcelain.tag_create(".", "1.2.3", annotated=True, message="Some tagging message".encode("utf-8"))
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/porcelain.py", line 1088, in tag_create
    r.object_store.add_object(tag_obj)
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/object_store.py", line 1011, in add_object
    path = self._get_shafile_path(obj.id)
                                  ^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 560, in id
    return self.sha().hexdigest().encode("ascii")
           ^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 544, in sha
    new_sha.update(self._header())
                   ^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 533, in _header
    return object_header(self.type_num, self.raw_length())
                                        ^^^^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 537, in raw_length
    return sum(map(len, self.as_raw_chunks()))
                        ^^^^^^^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 343, in as_raw_chunks
    self._chunked_text = self._serialize()
                         ^^^^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 833, in _serialize
    return list(_format_message(headers, body))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mark.jenner/dev/digieco/digieco-gitlab-pipeline/.venv/lib/python3.12/site-packages/dulwich/objects.py", line 730, in _format_message
    lines = value.split(b"\n")
            ^^^^^^^^^^^^^^^^^^
TypeError: must be str or None, not bytes
```@jelmer: You'd need to do the same for the actual name - it needs to be a bytestring too.@jennerm: Ah! Thanks.  That is working.
I overlooked that because that string worked without encoding when I was not annotating.
Thanks again for your help.

Solution patch:
diff --git a/dulwich/porcelain.py b/dulwich/porcelain.py
index 6b6e1fa0..419c2cd2 100644
--- a/dulwich/porcelain.py
+++ b/dulwich/porcelain.py
@@ -125,6 +125,7 @@ from .refs import (
     LOCAL_BRANCH_PREFIX,
     LOCAL_REMOTE_PREFIX,
     LOCAL_TAG_PREFIX,
+    Ref,
     _import_remote_refs,
 )
 from .repo import BaseRepo, Repo, get_user_identity
@@ -1039,15 +1040,15 @@ def submodule_list(repo):
 
 def tag_create(
     repo,
-    tag,
-    author=None,
-    message=None,
+    tag: Union[str, bytes],
+    author: Optional[Union[str, bytes]] = None,
+    message: Optional[Union[str, bytes]] = None,
     annotated=False,
-    objectish="HEAD",
+    objectish: Union[str, bytes] = "HEAD",
     tag_time=None,
     tag_timezone=None,
-    sign=False,
-    encoding=DEFAULT_ENCODING,
+    sign: bool = False,
+    encoding: str = DEFAULT_ENCODING,
 ) -> None:
     """Creates a tag in git via dulwich calls.
 
@@ -1067,12 +1068,25 @@ def tag_create(
     with open_repo_closing(repo) as r:
         object = parse_object(r, objectish)
 
+        if isinstance(tag, str):
+            tag = tag.encode(encoding)
+
         if annotated:
             # Create the tag object
             tag_obj = Tag()
             if author is None:
                 author = get_user_identity(r.get_config_stack())
+            elif isinstance(author, str):
+                author = author.encode(encoding)
+            else:
+                assert isinstance(author, bytes)
             tag_obj.tagger = author
+            if isinstance(message, str):
+                message = message.encode(encoding)
+            elif isinstance(message, bytes):
+                pass
+            else:
+                message = b""
             tag_obj.message = message + "\n".encode(encoding)
             tag_obj.name = tag
             tag_obj.object = (type(object), object.id)
@@ -1590,14 +1604,14 @@ def receive_pack(path=".", inf=None, outf=None) -> int:
     return 0
 
 
-def _make_branch_ref(name):
-    if getattr(name, "encode", None):
+def _make_branch_ref(name: Union[str, bytes]) -> Ref:
+    if isinstance(name, str):
         name = name.encode(DEFAULT_ENCODING)
     return LOCAL_BRANCH_PREFIX + name
 
 
-def _make_tag_ref(name):
-    if getattr(name, "encode", None):
+def _make_tag_ref(name: Union[str, bytes]) -> Ref:
+    if isinstance(name, str):
         name = name.encode(DEFAULT_ENCODING)
     return LOCAL_TAG_PREFIX + name
 


Test patch:
diff --git a/tests/test_porcelain.py b/tests/test_porcelain.py
index ffefc8f2..d898e638 100644
--- a/tests/test_porcelain.py
+++ b/tests/test_porcelain.py
@@ -1186,6 +1186,51 @@ Date:   Fri Jan 01 2010 00:00:00 +0000
 
 Test message.
 
+diff --git a/somename b/somename
+new file mode 100644
+index 0000000..ea5c7bf
+--- /dev/null
++++ b/somename
+@@ -0,0 +1 @@
++The Foo
+""",
+        )
+
+    def test_tag_unicode(self) -> None:
+        a = Blob.from_string(b"The Foo\n")
+        ta = Tree()
+        ta.add(b"somename", 0o100644, a.id)
+        ca = make_commit(tree=ta.id)
+        self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
+        porcelain.tag_create(
+            self.repo.path,
+            "tryme",
+            "foo <foo@bar.com>",
+            "bar",
+            annotated=True,
+            objectish=ca.id,
+            tag_time=1552854211,
+            tag_timezone=0,
+        )
+        outstream = StringIO()
+        porcelain.show(self.repo, objects=[b"refs/tags/tryme"], outstream=outstream)
+        self.maxDiff = None
+        self.assertMultiLineEqual(
+            outstream.getvalue(),
+            """\
+Tagger: foo <foo@bar.com>
+Date:   Sun Mar 17 2019 20:23:31 +0000
+
+bar
+
+--------------------------------------------------
+commit: 344da06c1bb85901270b3e8875c988a027ec087d
+Author: Test Author <test@nodomain.com>
+Committer: Test Committer <test@nodomain.com>
+Date:   Fri Jan 01 2010 00:00:00 +0000
+
+Test message.
+
 diff --git a/somename b/somename
 new file mode 100644
 index 0000000..ea5c7bf