aboutsummaryrefslogtreecommitdiff
path: root/object
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-06-07 10:19:14 +0000
committerGravatar Runxi Yu2026-06-07 10:20:49 +0000
commit2f3b8b0dc29547ca7406d00de38d3e3b425240dd (patch)
treeec9ae442ebb8f3d957e4404f20e06a262ab19286 /object
parentobject/commit: Update to the new testgit (diff)
signatureNo signature
object/tag: Add
Diffstat (limited to 'object')
-rw-r--r--object/parse.go3
-rw-r--r--object/tag/append.go53
-rw-r--r--object/tag/append_test.go76
-rw-r--r--object/tag/doc.go8
-rw-r--r--object/tag/malformed_test.go128
-rw-r--r--object/tag/parse.go143
-rw-r--r--object/tag/parse_test.go136
-rw-r--r--object/tag/roundtrip_test.go129
-rw-r--r--object/tag/tag.go25
-rw-r--r--object/tag/type.go10
10 files changed, 710 insertions, 1 deletions
diff --git a/object/parse.go b/object/parse.go
index 6a60407c..bbede773 100644
--- a/object/parse.go
+++ b/object/parse.go
@@ -8,6 +8,7 @@ import (
"lindenii.org/go/furgit/object/commit"
"lindenii.org/go/furgit/object/header"
"lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/tag"
"lindenii.org/go/furgit/object/typ"
)
@@ -46,7 +47,7 @@ func ParseWithoutHeader(ty typ.Type, body []byte, objectFormat id.ObjectFormat)
case typ.TypeCommit:
return commit.Parse(body, objectFormat) //nolint:wrapcheck
case typ.TypeTag:
- panic("TODO")
+ return tag.Parse(body, objectFormat) //nolint:wrapcheck
case typ.TypeUnknown:
return nil, typ.ErrInvalidType
default:
diff --git a/object/tag/append.go b/object/tag/append.go
new file mode 100644
index 00000000..e3763ec7
--- /dev/null
+++ b/object/tag/append.go
@@ -0,0 +1,53 @@
+package tag
+
+import (
+ "fmt"
+
+ "lindenii.org/go/furgit/object/header"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// AppendWithoutHeader renders the raw tag body bytes.
+func (tag *Tag) AppendWithoutHeader(dst []byte) ([]byte, error) {
+ dst = fmt.Appendf(dst, "object %s\n", tag.TargetID.String())
+ dst = append(dst, []byte("type ")...)
+ dst = append(dst, tag.TargetType.Name()...)
+ dst = append(dst, byte('\n'))
+ dst = append(dst, []byte("tag ")...)
+ dst = append(dst, tag.Name...)
+ dst = append(dst, byte('\n'))
+ dst = append(dst, []byte("tagger ")...)
+
+ dst, err := tag.Tagger.Append(dst)
+ if err != nil {
+ return dst, fmt.Errorf("object/tag: append tagger: %w", err)
+ }
+
+ dst = append(dst, byte('\n'))
+
+ for _, h := range tag.ExtraHeaders {
+ // GIGO on empty keys and such.
+ dst = append(dst, []byte(h.Key)...)
+ dst = append(dst, byte(' '))
+ dst = append(dst, h.Value...)
+ dst = append(dst, byte('\n'))
+ }
+
+ dst = append(dst, byte('\n'))
+ dst = append(dst, tag.Message...)
+
+ return dst, nil
+}
+
+// AppendWithHeader renders the raw object (header + body).
+func (tag *Tag) AppendWithHeader(dst []byte) ([]byte, error) {
+ // TODO: Try to not allocate?
+ body, err := tag.AppendWithoutHeader(nil)
+ if err != nil {
+ return dst, err
+ }
+
+ dst = header.Append(dst, typ.TypeTag, uint64(len(body)))
+
+ return append(dst, body...), nil
+}
diff --git a/object/tag/append_test.go b/object/tag/append_test.go
new file mode 100644
index 00000000..b49d2e4b
--- /dev/null
+++ b/object/tag/append_test.go
@@ -0,0 +1,76 @@
+package tag_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/signature"
+ "lindenii.org/go/furgit/object/tag"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+func TestAppendGitFsck(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ blobID, err := repo.HashObject(t, typ.TypeBlob, strings.NewReader("content\n"))
+ if err != nil {
+ t.Fatalf("HashObject(blob): %v", err)
+ }
+
+ tagObject := &tag.Tag{
+ TargetID: blobID,
+ TargetType: typ.TypeBlob,
+ Name: []byte("blob-tag"),
+ Tagger: signature.Signature{
+ Name: []byte("Test Tagger"),
+ Email: []byte("tagger@example.org"),
+ WhenUnix: 1234567890,
+ OffsetMinutes: 0,
+ },
+ Message: []byte("subject\n\nbody\n"),
+ }
+
+ rawBody, err := tagObject.AppendWithoutHeader(nil)
+ if err != nil {
+ t.Fatalf("AppendWithoutHeader: %v", err)
+ }
+
+ tagID, err := repo.HashObject(t, typ.TypeTag, bytes.NewReader(rawBody))
+ if err != nil {
+ t.Fatalf("HashObject(tag): %v", err)
+ }
+
+ err = repo.Fsck(t, testgit.FsckOptions{
+ Strict: true,
+ NoDangling: true,
+ }, tagID)
+ if err != nil {
+ t.Fatalf("Fsck: %v", err)
+ }
+
+ gitBody, err := repo.CatFile(t, typ.TypeTag, tagID)
+ if err != nil {
+ t.Fatalf("CatFile(tag): %v", err)
+ }
+
+ parsed, err := tag.Parse(gitBody, objectFormat)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+
+ assertTagEqual(t, parsed, tagObject)
+ })
+ }
+}
diff --git a/object/tag/doc.go b/object/tag/doc.go
new file mode 100644
index 00000000..5822de10
--- /dev/null
+++ b/object/tag/doc.go
@@ -0,0 +1,8 @@
+// Package tag provides parsed annotated tag objects and tag serialization.
+//
+// It parses annotated tags into ordinary Go values for reading and construction.
+// It does not preserve the exact original byte layout
+// needed for signature verification;
+// callers that need signature-verification payload fidelity
+// should use [lindenii.org/go/furgit/object/signed/tag].
+package tag
diff --git a/object/tag/malformed_test.go b/object/tag/malformed_test.go
new file mode 100644
index 00000000..2f51d027
--- /dev/null
+++ b/object/tag/malformed_test.go
@@ -0,0 +1,128 @@
+package tag_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/tag"
+ refname "lindenii.org/go/furgit/ref/name"
+)
+
+func TestParseMalformed(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ objectID := strings.Repeat("1", objectFormat.HexLen())
+ shortID := strings.Repeat("2", objectFormat.HexLen()-2)
+ object := "object " + objectID + "\n"
+ typ := "type commit\n"
+ name := "tag v1\n"
+
+ tagger := "tagger Test Tagger <tagger@example.org> 1234567890 +0000\n"
+ for _, tc := range []struct {
+ name string
+ body string
+ }{
+ {
+ name: "empty",
+ body: "",
+ },
+ {
+ name: "missing-object",
+ body: typ + name + tagger + "\nmessage\n",
+ },
+ {
+ name: "malformed-object",
+ body: "object not-an-oid\n" + typ + name + tagger + "\nmessage\n",
+ },
+ {
+ name: "short-object",
+ body: "object " + shortID + "\n" + typ + name + tagger + "\nmessage\n",
+ },
+ {
+ name: "missing-type",
+ body: object + name + tagger + "\nmessage\n",
+ },
+ {
+ name: "bad-type",
+ body: object + "type widget\n" + name + tagger + "\nmessage\n",
+ },
+ {
+ name: "missing-tag",
+ body: object + typ + tagger + "\nmessage\n",
+ },
+ {
+ name: "bad-tag-name",
+ body: object + typ + "tag bad tag\n" + tagger + "\nmessage\n",
+ },
+ {
+ name: "missing-tagger",
+ body: object + typ + name + "\nmessage\n",
+ },
+ {
+ name: "bad-tagger",
+ body: object + typ + name + "tagger Test Tagger <tagger@example.org> UTC\n\nmessage\n",
+ },
+ {
+ name: "duplicate-object",
+ body: object + typ + name + tagger + object + "\nmessage\n",
+ },
+ {
+ name: "duplicate-type",
+ body: object + typ + name + tagger + typ + "\nmessage\n",
+ },
+ {
+ name: "duplicate-tag",
+ body: object + typ + name + tagger + name + "\nmessage\n",
+ },
+ {
+ name: "duplicate-tagger",
+ body: object + typ + name + tagger + tagger + "\nmessage\n",
+ },
+ {
+ name: "extra-header-without-space",
+ body: object + typ + name + tagger + "encoding\n\nmessage\n",
+ },
+ {
+ name: "nonempty-message-without-blank-line",
+ body: object + typ + name + tagger + "message\n",
+ },
+ {
+ name: "unterminated-signature-continuation",
+ body: object + typ + name + tagger + "gpgsig header\n continuation",
+ },
+ {
+ name: "unterminated-extra-header",
+ body: object + typ + name + tagger + "encoding UTF-8",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := tag.Parse([]byte(tc.body), objectFormat)
+ if !errors.Is(err, tag.ErrInvalidTag) {
+ t.Fatalf("Parse error = %v, want ErrInvalidTag", err)
+ }
+ })
+ }
+
+ t.Run("bad-tag-name-wraps-ref-name-error", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := tag.Parse([]byte(object+typ+"tag bad tag\n"+tagger+"\nmessage\n"), objectFormat)
+ if !errors.Is(err, tag.ErrInvalidTag) {
+ t.Fatalf("Parse error = %v, want ErrInvalidTag", err)
+ }
+
+ if !errors.Is(err, refname.ErrInvalidName) {
+ t.Fatalf("Parse error = %v, want ErrInvalidName", err)
+ }
+ })
+ })
+ }
+}
diff --git a/object/tag/parse.go b/object/tag/parse.go
new file mode 100644
index 00000000..c5ea7e14
--- /dev/null
+++ b/object/tag/parse.go
@@ -0,0 +1,143 @@
+package tag
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/signature"
+ "lindenii.org/go/furgit/object/typ"
+ refname "lindenii.org/go/furgit/ref/name"
+)
+
+// ErrInvalidTag indicates a malformed tag object.
+var ErrInvalidTag = errors.New("object/tag: invalid tag")
+
+// Parse decodes a tag object body.
+func Parse(body []byte, objectFormat id.ObjectFormat) (*Tag, error) {
+ t := new(Tag)
+
+ i := 0
+
+ var err error
+
+ line, next, err := requiredHeaderLine(body, i, "object")
+ if err != nil {
+ return nil, err
+ }
+
+ t.TargetID, err = objectFormat.FromString(string(line))
+ if err != nil {
+ return nil, fmt.Errorf("%w: object: %w", ErrInvalidTag, err)
+ }
+
+ i = next
+
+ line, next, err = requiredHeaderLine(body, i, "type")
+ if err != nil {
+ return nil, err
+ }
+
+ t.TargetType, err = typ.Parse(string(line))
+ if err != nil {
+ return nil, fmt.Errorf("%w: type: %w", ErrInvalidTag, err)
+ }
+
+ i = next
+
+ line, next, err = requiredHeaderLine(body, i, "tag")
+ if err != nil {
+ return nil, err
+ }
+
+ _, err = refname.Tag(string(line))
+ if err != nil {
+ return nil, fmt.Errorf("%w: tag name: %w", ErrInvalidTag, err)
+ }
+
+ t.Name = append([]byte(nil), line...)
+ i = next
+
+ line, next, err = requiredHeaderLine(body, i, "tagger")
+ if err != nil {
+ return nil, err
+ }
+
+ tagger, err := signature.Parse(line)
+ if err != nil {
+ return nil, fmt.Errorf("%w: tagger: %w", ErrInvalidTag, err)
+ }
+
+ t.Tagger = *tagger
+ i = next
+
+ for i < len(body) {
+ lineStart := i
+
+ rel := bytes.IndexByte(body[i:], '\n')
+ if rel < 0 {
+ return nil, fmt.Errorf("%w: unterminated header line at offset %d", ErrInvalidTag, lineStart)
+ }
+
+ line := body[i : i+rel]
+ i += rel + 1
+
+ if len(line) == 0 {
+ t.Message = append([]byte(nil), body[i:]...)
+
+ return t, nil
+ }
+
+ key, value, found := bytes.Cut(line, []byte{' '})
+ if !found {
+ return nil, fmt.Errorf("%w: header line at offset %d has no ' ' separator", ErrInvalidTag, lineStart)
+ }
+
+ switch string(key) {
+ case "object", "type", "tag", "tagger":
+ return nil, fmt.Errorf("%w: unexpected %s header at offset %d", ErrInvalidTag, key, lineStart)
+ case "gpgsig", "gpgsig-sha256":
+ for i < len(body) {
+ nextRel := bytes.IndexByte(body[i:], '\n')
+ if nextRel < 0 {
+ return nil, fmt.Errorf("%w: unterminated signature header at offset %d", ErrInvalidTag, i)
+ }
+
+ if body[i] != ' ' {
+ break
+ }
+
+ i += nextRel + 1
+ }
+ default:
+ t.ExtraHeaders = append(t.ExtraHeaders, ExtraHeader{
+ Key: string(key),
+ Value: append([]byte(nil), value...),
+ })
+ }
+ }
+
+ return t, nil
+}
+
+func requiredHeaderLine(body []byte, offset int, want string) ([]byte, int, error) {
+ rel := bytes.IndexByte(body[offset:], '\n')
+ if rel < 0 {
+ return nil, offset, fmt.Errorf("%w: unterminated %s header at offset %d", ErrInvalidTag, want, offset)
+ }
+
+ line := body[offset : offset+rel]
+ next := offset + rel + 1
+
+ key, value, found := bytes.Cut(line, []byte{' '})
+ if !found {
+ return nil, offset, fmt.Errorf("%w: %s header at offset %d has no ' ' separator", ErrInvalidTag, want, offset)
+ }
+
+ if string(key) != want {
+ return nil, offset, fmt.Errorf("%w: expected %s header at offset %d, got %s", ErrInvalidTag, want, offset, key)
+ }
+
+ return value, next, nil
+}
diff --git a/object/tag/parse_test.go b/object/tag/parse_test.go
new file mode 100644
index 00000000..423fea30
--- /dev/null
+++ b/object/tag/parse_test.go
@@ -0,0 +1,136 @@
+package tag_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/tag"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+func TestParse(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ blobID, err := repo.HashObject(t, typ.TypeBlob, strings.NewReader("content\n"))
+ if err != nil {
+ t.Fatalf("HashObject(blob): %v", err)
+ }
+
+ treeID, err := repo.MkTree(t, []testgit.MkTreeEntry{
+ {Mode: "100644", Type: typ.TypeBlob, OID: blobID, Name: "file.txt"},
+ })
+ if err != nil {
+ t.Fatalf("MkTree: %v", err)
+ }
+
+ commitID, err := repo.CommitTree(t, treeID, testgit.CommitTreeOptions{
+ Message: "tag target subject\n\nbody",
+ Author: testgit.Identity{
+ Name: "Target Author",
+ Email: "target-author@example.org",
+ },
+ Committer: testgit.Identity{
+ Name: "Target Committer",
+ Email: "target-committer@example.org",
+ },
+ AuthorDate: "1234567890 +0000",
+ CommitterDate: "1234567891 +0000",
+ })
+ if err != nil {
+ t.Fatalf("CommitTree: %v", err)
+ }
+
+ tagID, err := repo.TagAnnotated(t, "v1.2.3", commitID, testgit.TagAnnotatedOptions{
+ Message: "tag subject\n\ntag body",
+ Tagger: testgit.Identity{
+ Name: "Test Tagger",
+ Email: "tagger@example.org",
+ },
+ TaggerDate: "1234567999 -0330",
+ })
+ if err != nil {
+ t.Fatalf("TagAnnotated: %v", err)
+ }
+
+ rawBody, err := repo.CatFile(t, typ.TypeTag, tagID)
+ if err != nil {
+ t.Fatalf("CatFile: %v", err)
+ }
+
+ parsed, err := tag.Parse(rawBody, objectFormat)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+
+ if parsed.TargetID != commitID {
+ t.Fatalf("target id = %s, want %s", parsed.TargetID, commitID)
+ }
+
+ if parsed.TargetType != typ.TypeCommit {
+ t.Fatalf("target type = %v, want %v", parsed.TargetType, typ.TypeCommit)
+ }
+
+ if !bytes.Equal(parsed.Name, []byte("v1.2.3")) {
+ t.Fatalf("name = %q, want %q", parsed.Name, "v1.2.3")
+ }
+
+ if !bytes.Equal(parsed.Tagger.Name, []byte("Test Tagger")) {
+ t.Fatalf("tagger name = %q, want %q", parsed.Tagger.Name, "Test Tagger")
+ }
+
+ if !bytes.Equal(parsed.Tagger.Email, []byte("tagger@example.org")) {
+ t.Fatalf("tagger email = %q, want %q", parsed.Tagger.Email, "tagger@example.org")
+ }
+
+ if parsed.Tagger.WhenUnix != 1234567999 {
+ t.Fatalf("tagger time = %d, want %d", parsed.Tagger.WhenUnix, int64(1234567999))
+ }
+
+ if parsed.Tagger.OffsetMinutes != -210 {
+ t.Fatalf("tagger offset = %d, want %d", parsed.Tagger.OffsetMinutes, int32(-210))
+ }
+
+ if !bytes.Equal(parsed.Message, []byte("tag subject\n\ntag body\n")) {
+ t.Fatalf("message = %q, want %q", parsed.Message, "tag subject\n\ntag body\n")
+ }
+ })
+ }
+}
+
+func TestParseEmptyMessageWithoutBlankLine(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ objectID := strings.Repeat("1", objectFormat.HexLen())
+ body := "" +
+ "object " + objectID + "\n" +
+ "type commit\n" +
+ "tag empty-message-tag\n" +
+ "tagger Test Tagger <tagger@example.org> 1234567890 +0000\n"
+
+ got, err := tag.Parse([]byte(body), objectFormat)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+
+ if len(got.Message) != 0 {
+ t.Fatalf("message = %q, want empty", got.Message)
+ }
+ })
+ }
+}
diff --git a/object/tag/roundtrip_test.go b/object/tag/roundtrip_test.go
new file mode 100644
index 00000000..8e754854
--- /dev/null
+++ b/object/tag/roundtrip_test.go
@@ -0,0 +1,129 @@
+package tag_test
+
+import (
+ "bytes"
+ "slices"
+ "strings"
+ "testing"
+
+ "lindenii.org/go/furgit/internal/testgit"
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/signature"
+ "lindenii.org/go/furgit/object/tag"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+func TestRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat})
+ if err != nil {
+ t.Fatalf("NewRepo: %v", err)
+ }
+
+ blobID, err := repo.HashObject(t, typ.TypeBlob, strings.NewReader("roundtrip\n"))
+ if err != nil {
+ t.Fatalf("HashObject(blob): %v", err)
+ }
+
+ want := &tag.Tag{
+ TargetID: blobID,
+ TargetType: typ.TypeBlob,
+ Name: []byte("roundtrip-tag"),
+ Tagger: signature.Signature{
+ Name: []byte("Round Trip Tagger"),
+ Email: []byte("roundtrip-tagger@example.org"),
+ WhenUnix: 1234567999,
+ OffsetMinutes: 330,
+ },
+ Message: []byte("roundtrip subject\n\nroundtrip body\n\n"),
+ ExtraHeaders: []tag.ExtraHeader{
+ {Key: "encoding", Value: []byte("UTF-8")},
+ {Key: "x-test-header", Value: []byte("value")},
+ },
+ }
+
+ rawBody, err := want.AppendWithoutHeader(nil)
+ if err != nil {
+ t.Fatalf("AppendWithoutHeader: %v", err)
+ }
+
+ roundTripID, err := repo.HashObject(t, typ.TypeTag, bytes.NewReader(rawBody))
+ if err != nil {
+ t.Fatalf("HashObject(tag): %v", err)
+ }
+
+ err = repo.Fsck(t, testgit.FsckOptions{
+ Strict: true,
+ NoDangling: true,
+ }, roundTripID)
+ if err != nil {
+ t.Fatalf("Fsck: %v", err)
+ }
+
+ gitBody, err := repo.CatFile(t, typ.TypeTag, roundTripID)
+ if err != nil {
+ t.Fatalf("CatFile: %v", err)
+ }
+
+ got, err := tag.Parse(gitBody, objectFormat)
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+
+ assertTagEqual(t, got, want)
+ })
+ }
+}
+
+func assertTagEqual(t *testing.T, got *tag.Tag, want *tag.Tag) {
+ t.Helper()
+
+ if got.TargetID != want.TargetID {
+ t.Fatalf("target id = %s, want %s", got.TargetID, want.TargetID)
+ }
+
+ if got.TargetType != want.TargetType {
+ t.Fatalf("target type = %v, want %v", got.TargetType, want.TargetType)
+ }
+
+ if !bytes.Equal(got.Name, want.Name) {
+ t.Fatalf("name = %q, want %q", got.Name, want.Name)
+ }
+
+ assertSignatureEqual(t, "tagger", got.Tagger, want.Tagger)
+
+ if !bytes.Equal(got.Message, want.Message) {
+ t.Fatalf("message = %q, want %q", got.Message, want.Message)
+ }
+
+ if !slices.EqualFunc(got.ExtraHeaders, want.ExtraHeaders, func(gotHeader tag.ExtraHeader, wantHeader tag.ExtraHeader) bool {
+ return gotHeader.Key == wantHeader.Key && bytes.Equal(gotHeader.Value, wantHeader.Value)
+ }) {
+ t.Fatalf("extra headers = %+v, want %+v", got.ExtraHeaders, want.ExtraHeaders)
+ }
+}
+
+func assertSignatureEqual(t *testing.T, name string, got signature.Signature, want signature.Signature) {
+ t.Helper()
+
+ if !bytes.Equal(got.Name, want.Name) {
+ t.Fatalf("%s name = %q, want %q", name, got.Name, want.Name)
+ }
+
+ if !bytes.Equal(got.Email, want.Email) {
+ t.Fatalf("%s email = %q, want %q", name, got.Email, want.Email)
+ }
+
+ if got.WhenUnix != want.WhenUnix {
+ t.Fatalf("%s time = %d, want %d", name, got.WhenUnix, want.WhenUnix)
+ }
+
+ if got.OffsetMinutes != want.OffsetMinutes {
+ t.Fatalf("%s offset = %d, want %d", name, got.OffsetMinutes, want.OffsetMinutes)
+ }
+}
diff --git a/object/tag/tag.go b/object/tag/tag.go
new file mode 100644
index 00000000..f4b36c30
--- /dev/null
+++ b/object/tag/tag.go
@@ -0,0 +1,25 @@
+package tag
+
+import (
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/object/signature"
+ "lindenii.org/go/furgit/object/typ"
+)
+
+// Tag represents a fully materialized Git annotated tag object.
+//
+// Labels: MT-Unsafe.
+type Tag struct {
+ TargetID id.ObjectID
+ TargetType typ.Type
+ Name []byte
+ Tagger signature.Signature
+ Message []byte
+ ExtraHeaders []ExtraHeader
+}
+
+// ExtraHeader represents an extra header in a Git tag object.
+type ExtraHeader struct {
+ Key string
+ Value []byte
+}
diff --git a/object/tag/type.go b/object/tag/type.go
new file mode 100644
index 00000000..0987a6c1
--- /dev/null
+++ b/object/tag/type.go
@@ -0,0 +1,10 @@
+package tag
+
+import "lindenii.org/go/furgit/object/typ"
+
+// ObjectType returns TypeTag.
+func (tag *Tag) ObjectType() typ.Type {
+ _ = tag
+
+ return typ.TypeTag
+}