aboutsummaryrefslogtreecommitdiff
path: root/object
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-02-20 21:20:35 +0800
committerGravatar Runxi Yu2026-02-20 21:51:00 +0800
commitb5a545a3d883026d61beac5556fec2a45e9ec3d3 (patch)
tree6281f89df21cb4b63a16bbcb057c5f186c40b148 /object
parenttestgit: Add test harnesses (diff)
signatureNo signature
object: Add basic object code
Diffstat (limited to 'object')
-rw-r--r--object/blob.go12
-rw-r--r--object/blob_parse.go6
-rw-r--r--object/blob_serialize.go13
-rw-r--r--object/commit.go20
-rw-r--r--object/commit_parse.go86
-rw-r--r--object/commit_serialize.go69
-rw-r--r--object/extraheader.go7
-rw-r--r--object/ident.go122
-rw-r--r--object/object.go89
-rw-r--r--object/tag.go18
-rw-r--r--object/tag_parse.go81
-rw-r--r--object/tag_serialize.go57
-rw-r--r--object/tree.go153
-rw-r--r--object/tree_parse.go57
-rw-r--r--object/tree_serialize.go40
15 files changed, 830 insertions, 0 deletions
diff --git a/object/blob.go b/object/blob.go
new file mode 100644
index 00000000..03d17df6
--- /dev/null
+++ b/object/blob.go
@@ -0,0 +1,12 @@
+package object
+
+// Blob represents a Git blob object.
+type Blob struct {
+ Data []byte
+}
+
+// ObjectType returns TypeBlob.
+func (blob *Blob) ObjectType() Type {
+ _ = blob
+ return TypeBlob
+}
diff --git a/object/blob_parse.go b/object/blob_parse.go
new file mode 100644
index 00000000..61aacfac
--- /dev/null
+++ b/object/blob_parse.go
@@ -0,0 +1,6 @@
+package object
+
+// ParseBlob decodes a blob object body.
+func ParseBlob(body []byte) (*Blob, error) {
+ return &Blob{Data: append([]byte(nil), body...)}, nil
+}
diff --git a/object/blob_serialize.go b/object/blob_serialize.go
new file mode 100644
index 00000000..c1818c20
--- /dev/null
+++ b/object/blob_serialize.go
@@ -0,0 +1,13 @@
+package object
+
+// Serialize renders the raw object (header + body).
+func (blob *Blob) Serialize() ([]byte, error) {
+ header, err := headerForType(TypeBlob, blob.Data)
+ if err != nil {
+ return nil, err
+ }
+ raw := make([]byte, len(header)+len(blob.Data))
+ copy(raw, header)
+ copy(raw[len(header):], blob.Data)
+ return raw, nil
+}
diff --git a/object/commit.go b/object/commit.go
new file mode 100644
index 00000000..0183ff02
--- /dev/null
+++ b/object/commit.go
@@ -0,0 +1,20 @@
+package object
+
+import "codeberg.org/lindenii/furgit/oid"
+
+// Commit represents a Git commit object.
+type Commit struct {
+ Tree oid.ObjectID
+ Parents []oid.ObjectID
+ Author Ident
+ Committer Ident
+ Message []byte
+ ChangeID string
+ ExtraHeaders []ExtraHeader
+}
+
+// ObjectType returns TypeCommit.
+func (commit *Commit) ObjectType() Type {
+ _ = commit
+ return TypeCommit
+}
diff --git a/object/commit_parse.go b/object/commit_parse.go
new file mode 100644
index 00000000..acc90951
--- /dev/null
+++ b/object/commit_parse.go
@@ -0,0 +1,86 @@
+package object
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/oid"
+)
+
+// ParseCommit decodes a commit object body.
+func ParseCommit(body []byte, algo oid.Algorithm) (*Commit, error) {
+ if algo.Size() == 0 {
+ return nil, ErrInvalidObject
+ }
+
+ c := new(Commit)
+ i := 0
+ for i < len(body) {
+ rel := bytes.IndexByte(body[i:], '\n')
+ if rel < 0 {
+ return nil, errors.New("object: commit: missing newline")
+ }
+ line := body[i : i+rel]
+ i += rel + 1
+ if len(line) == 0 {
+ break
+ }
+
+ key, value, found := bytes.Cut(line, []byte{' '})
+ if !found {
+ return nil, errors.New("object: commit: malformed header")
+ }
+
+ switch string(key) {
+ case "tree":
+ id, err := oid.ParseHex(algo, string(value))
+ if err != nil {
+ return nil, fmt.Errorf("object: commit: tree: %w", err)
+ }
+ c.Tree = id
+ case "parent":
+ id, err := oid.ParseHex(algo, string(value))
+ if err != nil {
+ return nil, fmt.Errorf("object: commit: parent: %w", err)
+ }
+ c.Parents = append(c.Parents, id)
+ case "author":
+ idt, err := ParseIdent(value)
+ if err != nil {
+ return nil, fmt.Errorf("object: commit: author: %w", err)
+ }
+ c.Author = *idt
+ case "committer":
+ idt, err := ParseIdent(value)
+ if err != nil {
+ return nil, fmt.Errorf("object: commit: committer: %w", err)
+ }
+ c.Committer = *idt
+ case "change-id":
+ c.ChangeID = string(value)
+ case "gpgsig", "gpgsig-sha256":
+ for i < len(body) {
+ nextRel := bytes.IndexByte(body[i:], '\n')
+ if nextRel < 0 {
+ return nil, errors.New("object: commit: unterminated gpgsig")
+ }
+ if body[i] != ' ' {
+ break
+ }
+ i += nextRel + 1
+ }
+ default:
+ c.ExtraHeaders = append(c.ExtraHeaders, ExtraHeader{
+ Key: string(key),
+ Value: append([]byte(nil), value...),
+ })
+ }
+ }
+
+ if i > len(body) {
+ return nil, ErrInvalidObject
+ }
+ c.Message = append([]byte(nil), body[i:]...)
+ return c, nil
+}
diff --git a/object/commit_serialize.go b/object/commit_serialize.go
new file mode 100644
index 00000000..b06e597c
--- /dev/null
+++ b/object/commit_serialize.go
@@ -0,0 +1,69 @@
+package object
+
+import (
+ "bytes"
+ "fmt"
+)
+
+func (commit *Commit) serialize() ([]byte, error) {
+ var buf bytes.Buffer
+
+ if commit.Tree.Size() == 0 {
+ return nil, ErrInvalidObject
+ }
+ fmt.Fprintf(&buf, "tree %s\n", commit.Tree.String())
+ for _, parent := range commit.Parents {
+ fmt.Fprintf(&buf, "parent %s\n", parent.String())
+ }
+
+ authorBytes, err := commit.Author.Serialize()
+ if err != nil {
+ return nil, err
+ }
+ buf.WriteString("author ")
+ buf.Write(authorBytes)
+ buf.WriteByte('\n')
+
+ committerBytes, err := commit.Committer.Serialize()
+ if err != nil {
+ return nil, err
+ }
+ buf.WriteString("committer ")
+ buf.Write(committerBytes)
+ buf.WriteByte('\n')
+
+ if commit.ChangeID != "" {
+ buf.WriteString("change-id ")
+ buf.WriteString(commit.ChangeID)
+ buf.WriteByte('\n')
+ }
+ for _, h := range commit.ExtraHeaders {
+ if h.Key == "" {
+ return nil, ErrInvalidObject
+ }
+ buf.WriteString(h.Key)
+ buf.WriteByte(' ')
+ buf.Write(h.Value)
+ buf.WriteByte('\n')
+ }
+
+ buf.WriteByte('\n')
+ buf.Write(commit.Message)
+ return buf.Bytes(), nil
+}
+
+// Serialize renders the raw object (header + body).
+func (commit *Commit) Serialize() ([]byte, error) {
+ body, err := commit.serialize()
+ if err != nil {
+ return nil, err
+ }
+ header, err := headerForType(TypeCommit, body)
+ if err != nil {
+ return nil, err
+ }
+ raw := make([]byte, len(header)+len(body))
+ copy(raw, header)
+ copy(raw[len(header):], body)
+ return raw, nil
+}
diff --git a/object/extraheader.go b/object/extraheader.go
new file mode 100644
index 00000000..4ad1ec09
--- /dev/null
+++ b/object/extraheader.go
@@ -0,0 +1,7 @@
+package object
+
+// ExtraHeader represents an extra header in a Git object.
+type ExtraHeader struct {
+ Key string
+ Value []byte
+}
diff --git a/object/ident.go b/object/ident.go
new file mode 100644
index 00000000..e601802a
--- /dev/null
+++ b/object/ident.go
@@ -0,0 +1,122 @@
+package object
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Ident represents a Git identity (author/committer/tagger).
+type Ident struct {
+ Name []byte
+ Email []byte
+ WhenUnix int64
+ OffsetMinutes int32
+}
+
+// ParseIdent parses a canonical Git identity line:
+// "Name <email> 123456789 +0000".
+func ParseIdent(line []byte) (*Ident, error) {
+ lt := bytes.IndexByte(line, '<')
+ if lt < 0 {
+ return nil, errors.New("object: ident: missing opening <")
+ }
+ gtRel := bytes.IndexByte(line[lt+1:], '>')
+ if gtRel < 0 {
+ return nil, errors.New("object: ident: missing closing >")
+ }
+ gt := lt + 1 + gtRel
+
+ nameBytes := append([]byte(nil), bytes.TrimRight(line[:lt], " ")...)
+ emailBytes := append([]byte(nil), line[lt+1:gt]...)
+
+ rest := line[gt+1:]
+ if len(rest) == 0 || rest[0] != ' ' {
+ return nil, errors.New("object: ident: missing timestamp separator")
+ }
+ rest = rest[1:]
+ sp := bytes.IndexByte(rest, ' ')
+ if sp < 0 {
+ return nil, errors.New("object: ident: missing timezone separator")
+ }
+ when, err := strconv.ParseInt(string(rest[:sp]), 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("object: ident: invalid timestamp: %w", err)
+ }
+
+ tz := rest[sp+1:]
+ if len(tz) < 5 {
+ return nil, errors.New("object: ident: invalid timezone encoding")
+ }
+ sign := 1
+ switch tz[0] {
+ case '-':
+ sign = -1
+ case '+':
+ default:
+ return nil, errors.New("object: ident: invalid timezone sign")
+ }
+
+ hh, err := strconv.Atoi(string(tz[1:3]))
+ if err != nil {
+ return nil, fmt.Errorf("object: ident: invalid timezone hours: %w", err)
+ }
+ mm, err := strconv.Atoi(string(tz[3:5]))
+ if err != nil {
+ return nil, fmt.Errorf("object: ident: invalid timezone minutes: %w", err)
+ }
+ if hh < 0 || hh > 23 {
+ return nil, errors.New("object: ident: invalid timezone hours range")
+ }
+ if mm < 0 || mm > 59 {
+ return nil, errors.New("object: ident: invalid timezone minutes range")
+ }
+ total := int64(hh)*60 + int64(mm)
+ if total > math.MaxInt32 {
+ return nil, errors.New("object: ident: timezone overflow")
+ }
+
+ offset := int32(total)
+ if sign < 0 {
+ offset = -offset
+ }
+ return &Ident{
+ Name: nameBytes,
+ Email: emailBytes,
+ WhenUnix: when,
+ OffsetMinutes: offset,
+ }, nil
+}
+
+// Serialize renders the identity in canonical Git format.
+func (ident Ident) Serialize() ([]byte, error) {
+ var b strings.Builder
+ b.Grow(len(ident.Name) + len(ident.Email) + 32)
+ b.Write(ident.Name)
+ b.WriteString(" <")
+ b.Write(ident.Email)
+ b.WriteString("> ")
+ b.WriteString(strconv.FormatInt(ident.WhenUnix, 10))
+ b.WriteByte(' ')
+
+ offset := ident.OffsetMinutes
+ sign := '+'
+ if offset < 0 {
+ sign = '-'
+ offset = -offset
+ }
+ hh := offset / 60
+ mm := offset % 60
+ fmt.Fprintf(&b, "%c%02d%02d", sign, hh, mm)
+ return []byte(b.String()), nil
+}
+
+// When returns a time.Time with the identity's timezone offset.
+func (ident Ident) When() time.Time {
+ loc := time.FixedZone("git", int(ident.OffsetMinutes)*60)
+ return time.Unix(ident.WhenUnix, 0).In(loc)
+}
diff --git a/object/object.go b/object/object.go
new file mode 100644
index 00000000..c40769a7
--- /dev/null
+++ b/object/object.go
@@ -0,0 +1,89 @@
+// Package object provides Git object models and codecs.
+package object
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "strconv"
+)
+
+var (
+ // ErrInvalidObject indicates malformed serialized data.
+ ErrInvalidObject = errors.New("object: invalid object encoding")
+ // ErrNotFound indicates missing entries in in-memory lookups.
+ ErrNotFound = errors.New("object: not found")
+)
+
+// Type mirrors Git object type tags.
+type Type uint8
+
+const (
+ TypeInvalid Type = 0
+ TypeCommit Type = 1
+ TypeTree Type = 2
+ TypeBlob Type = 3
+ TypeTag Type = 4
+ TypeFuture Type = 5
+ TypeOfsDelta Type = 6
+ TypeRefDelta Type = 7
+)
+
+const (
+ typeNameBlob = "blob"
+ typeNameTree = "tree"
+ typeNameCommit = "commit"
+ typeNameTag = "tag"
+)
+
+// Object is a Git object that can serialize itself.
+type Object interface {
+ ObjectType() Type
+ Serialize() ([]byte, error)
+}
+
+// ParseTypeName parses a canonical Git object type name.
+func ParseTypeName(name string) (Type, error) {
+ switch name {
+ case typeNameBlob:
+ return TypeBlob, nil
+ case typeNameTree:
+ return TypeTree, nil
+ case typeNameCommit:
+ return TypeCommit, nil
+ case typeNameTag:
+ return TypeTag, nil
+ default:
+ return TypeInvalid, ErrInvalidObject
+ }
+}
+
+func typeName(ty Type) (string, error) {
+ switch ty {
+ case TypeBlob:
+ return typeNameBlob, nil
+ case TypeTree:
+ return typeNameTree, nil
+ case TypeCommit:
+ return typeNameCommit, nil
+ case TypeTag:
+ return typeNameTag, nil
+ default:
+ return "", fmt.Errorf("object: unsupported type %d", ty)
+ }
+}
+
+func headerForType(ty Type, body []byte) ([]byte, error) {
+ tyStr, err := typeName(ty)
+ if err != nil {
+ return nil, err
+ }
+ size := strconv.Itoa(len(body))
+ var buf bytes.Buffer
+ buf.Grow(len(tyStr) + len(size) + 2)
+ buf.WriteString(tyStr)
+ buf.WriteByte(' ')
+ buf.WriteString(size)
+ buf.WriteByte(0)
+ return buf.Bytes(), nil
+}
diff --git a/object/tag.go b/object/tag.go
new file mode 100644
index 00000000..ec91ffde
--- /dev/null
+++ b/object/tag.go
@@ -0,0 +1,18 @@
+package object
+
+import "codeberg.org/lindenii/furgit/oid"
+
+// Tag represents a Git annotated tag object.
+type Tag struct {
+ Target oid.ObjectID
+ TargetType Type
+ Name []byte
+ Tagger *Ident
+ Message []byte
+}
+
+// ObjectType returns TypeTag.
+func (tag *Tag) ObjectType() Type {
+ _ = tag
+ return TypeTag
+}
diff --git a/object/tag_parse.go b/object/tag_parse.go
new file mode 100644
index 00000000..565b9f89
--- /dev/null
+++ b/object/tag_parse.go
@@ -0,0 +1,81 @@
+package object
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/oid"
+)
+
+// ParseTag decodes a tag object body.
+func ParseTag(body []byte, algo oid.Algorithm) (*Tag, error) {
+ if algo.Size() == 0 {
+ return nil, ErrInvalidObject
+ }
+
+ t := new(Tag)
+ i := 0
+ var haveTarget, haveType bool
+
+ for i < len(body) {
+ rel := bytes.IndexByte(body[i:], '\n')
+ if rel < 0 {
+ return nil, errors.New("object: tag: missing newline")
+ }
+ line := body[i : i+rel]
+ i += rel + 1
+ if len(line) == 0 {
+ break
+ }
+
+ key, value, found := bytes.Cut(line, []byte{' '})
+ if !found {
+ return nil, errors.New("object: tag: malformed header")
+ }
+
+ switch string(key) {
+ case "object":
+ id, err := oid.ParseHex(algo, string(value))
+ if err != nil {
+ return nil, fmt.Errorf("object: tag: object: %w", err)
+ }
+ t.Target = id
+ haveTarget = true
+ case "type":
+ ty, err := ParseTypeName(string(value))
+ if err != nil {
+ return nil, errors.New("object: tag: unknown target type")
+ }
+ t.TargetType = ty
+ haveType = true
+ case "tag":
+ t.Name = append([]byte(nil), value...)
+ case "tagger":
+ idt, err := ParseIdent(value)
+ if err != nil {
+ return nil, fmt.Errorf("object: tag: tagger: %w", err)
+ }
+ t.Tagger = idt
+ case "gpgsig", "gpgsig-sha256":
+ for i < len(body) {
+ nextRel := bytes.IndexByte(body[i:], '\n')
+ if nextRel < 0 {
+ return nil, errors.New("object: tag: unterminated gpgsig")
+ }
+ if body[i] != ' ' {
+ break
+ }
+ i += nextRel + 1
+ }
+ default:
+ // Ignore unknown headers for now.
+ }
+ }
+
+ if !haveTarget || !haveType {
+ return nil, errors.New("object: tag: missing required headers")
+ }
+ t.Message = append([]byte(nil), body[i:]...)
+ return t, nil
+}
diff --git a/object/tag_serialize.go b/object/tag_serialize.go
new file mode 100644
index 00000000..9600b81f
--- /dev/null
+++ b/object/tag_serialize.go
@@ -0,0 +1,57 @@
+package object
+
+import (
+ "bytes"
+ "fmt"
+)
+
+func (tag *Tag) serialize() ([]byte, error) {
+ if tag.Target.Size() == 0 {
+ return nil, ErrInvalidObject
+ }
+
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "object %s\n", tag.Target.String())
+
+ tyName, err := typeName(tag.TargetType)
+ if err != nil {
+ return nil, fmt.Errorf("object: tag: invalid target type %d", tag.TargetType)
+ }
+ buf.WriteString("type ")
+ buf.WriteString(tyName)
+ buf.WriteByte('\n')
+
+ buf.WriteString("tag ")
+ buf.Write(tag.Name)
+ buf.WriteByte('\n')
+
+ if tag.Tagger != nil {
+ taggerBytes, err := tag.Tagger.Serialize()
+ if err != nil {
+ return nil, err
+ }
+ buf.WriteString("tagger ")
+ buf.Write(taggerBytes)
+ buf.WriteByte('\n')
+ }
+
+ buf.WriteByte('\n')
+ buf.Write(tag.Message)
+ return buf.Bytes(), nil
+}
+
+// Serialize renders the raw object (header + body).
+func (tag *Tag) Serialize() ([]byte, error) {
+ body, err := tag.serialize()
+ if err != nil {
+ return nil, err
+ }
+ header, err := headerForType(TypeTag, body)
+ if err != nil {
+ return nil, err
+ }
+ raw := make([]byte, len(header)+len(body))
+ copy(raw, header)
+ copy(raw[len(header):], body)
+ return raw, nil
+}
diff --git a/object/tree.go b/object/tree.go
new file mode 100644
index 00000000..a922f2ab
--- /dev/null
+++ b/object/tree.go
@@ -0,0 +1,153 @@
+package object
+
+import (
+ "bytes"
+ "fmt"
+ "sort"
+
+ "codeberg.org/lindenii/furgit/oid"
+)
+
+// FileMode represents the mode of a file in a Git tree.
+type FileMode uint32
+
+const (
+ FileModeDir FileMode = 0o40000
+ FileModeRegular FileMode = 0o100644
+ FileModeExecutable FileMode = 0o100755
+ FileModeSymlink FileMode = 0o120000
+ FileModeGitlink FileMode = 0o160000
+)
+
+// TreeEntry represents a single entry in a tree.
+type TreeEntry struct {
+ Mode FileMode
+ Name []byte
+ ID oid.ObjectID
+}
+
+// Tree represents a Git tree object.
+type Tree struct {
+ Entries []TreeEntry
+}
+
+// ObjectType returns TypeTree.
+func (tree *Tree) ObjectType() Type {
+ _ = tree
+ return TypeTree
+}
+
+// Entry looks up a tree entry by name.
+func (tree *Tree) Entry(name []byte) *TreeEntry {
+ if len(tree.Entries) == 0 {
+ return nil
+ }
+ if e := tree.entry(name, true); e != nil {
+ return e
+ }
+ return tree.entry(name, false)
+}
+
+func (tree *Tree) entry(name []byte, searchIsTree bool) *TreeEntry {
+ low, high := 0, len(tree.Entries)-1
+ for low <= high {
+ mid := low + (high-low)/2
+ entry := &tree.Entries[mid]
+ cmp := TreeEntryNameCompare(entry.Name, entry.Mode, name, searchIsTree)
+ if cmp == 0 {
+ if bytes.Equal(entry.Name, name) {
+ return entry
+ }
+ return nil
+ }
+ if cmp < 0 {
+ low = mid + 1
+ } else {
+ high = mid - 1
+ }
+ }
+ return nil
+}
+
+// InsertEntry inserts a tree entry while preserving Git ordering.
+func (tree *Tree) InsertEntry(newEntry TreeEntry) error {
+ if tree == nil {
+ return ErrInvalidObject
+ }
+ if tree.entry(newEntry.Name, true) != nil || tree.entry(newEntry.Name, false) != nil {
+ return fmt.Errorf("object: tree: entry %q already exists", newEntry.Name)
+ }
+ newIsTree := newEntry.Mode == FileModeDir
+ insertAt := sort.Search(len(tree.Entries), func(i int) bool {
+ return TreeEntryNameCompare(tree.Entries[i].Name, tree.Entries[i].Mode, newEntry.Name, newIsTree) >= 0
+ })
+ tree.Entries = append(tree.Entries, TreeEntry{})
+ copy(tree.Entries[insertAt+1:], tree.Entries[insertAt:])
+ tree.Entries[insertAt] = newEntry
+ return nil
+}
+
+// RemoveEntry removes a tree entry by name.
+func (tree *Tree) RemoveEntry(name []byte) error {
+ if tree == nil {
+ return ErrInvalidObject
+ }
+ if len(tree.Entries) == 0 {
+ return ErrNotFound
+ }
+ for i := range tree.Entries {
+ if bytes.Equal(tree.Entries[i].Name, name) {
+ copy(tree.Entries[i:], tree.Entries[i+1:])
+ tree.Entries = tree.Entries[:len(tree.Entries)-1]
+ return nil
+ }
+ }
+ return ErrNotFound
+}
+
+// TreeEntryNameCompare compares names using Git tree ordering rules.
+func TreeEntryNameCompare(entryName []byte, entryMode FileMode, searchName []byte, searchIsTree bool) int {
+ isEntryTree := entryMode == FileModeDir
+
+ entryLen := len(entryName)
+ if isEntryTree {
+ entryLen++
+ }
+ searchLen := len(searchName)
+ if searchIsTree {
+ searchLen++
+ }
+
+ n := entryLen
+ if searchLen < n {
+ n = searchLen
+ }
+
+ for i := 0; i < n; i++ {
+ var ec, sc byte
+ if i < len(entryName) {
+ ec = entryName[i]
+ } else {
+ ec = '/'
+ }
+ if i < len(searchName) {
+ sc = searchName[i]
+ } else {
+ sc = '/'
+ }
+ if ec < sc {
+ return -1
+ }
+ if ec > sc {
+ return 1
+ }
+ }
+
+ if entryLen < searchLen {
+ return -1
+ }
+ if entryLen > searchLen {
+ return 1
+ }
+ return 0
+}
diff --git a/object/tree_parse.go b/object/tree_parse.go
new file mode 100644
index 00000000..e1e560b6
--- /dev/null
+++ b/object/tree_parse.go
@@ -0,0 +1,57 @@
+package object
+
+import (
+ "bytes"
+ "fmt"
+ "strconv"
+
+ "codeberg.org/lindenii/furgit/oid"
+)
+
+// ParseTree decodes a tree object body.
+func ParseTree(body []byte, algo oid.Algorithm) (*Tree, error) {
+ if algo.Size() == 0 {
+ return nil, ErrInvalidObject
+ }
+
+ var entries []TreeEntry
+ i := 0
+ for i < len(body) {
+ space := bytes.IndexByte(body[i:], ' ')
+ if space < 0 {
+ return nil, fmt.Errorf("object: tree: missing mode terminator")
+ }
+ modeBytes := body[i : i+space]
+ i += space + 1
+
+ nul := bytes.IndexByte(body[i:], 0)
+ if nul < 0 {
+ return nil, fmt.Errorf("object: tree: missing name terminator")
+ }
+ nameBytes := body[i : i+nul]
+ i += nul + 1
+
+ idEnd := i + algo.Size()
+ if idEnd > len(body) {
+ return nil, fmt.Errorf("object: tree: truncated child object id")
+ }
+ id, err := oid.FromBytes(algo, body[i:idEnd])
+ if err != nil {
+ return nil, err
+ }
+ i = idEnd
+
+ mode, err := strconv.ParseUint(string(modeBytes), 8, 32)
+ if err != nil {
+ return nil, fmt.Errorf("object: tree: parse mode: %w", err)
+ }
+
+ entries = append(entries, TreeEntry{
+ Mode: FileMode(mode),
+ Name: append([]byte(nil), nameBytes...),
+ ID: id,
+ })
+ }
+
+ return &Tree{Entries: entries}, nil
+}
diff --git a/object/tree_serialize.go b/object/tree_serialize.go
new file mode 100644
index 00000000..b6182a03
--- /dev/null
+++ b/object/tree_serialize.go
@@ -0,0 +1,40 @@
+package object
+
+import "strconv"
+
+func (tree *Tree) serialize() []byte {
+ var bodyLen int
+ for _, entry := range tree.Entries {
+ mode := strconv.FormatUint(uint64(entry.Mode), 8)
+ bodyLen += len(mode) + 1 + len(entry.Name) + 1 + entry.ID.Size()
+ }
+
+ body := make([]byte, bodyLen)
+ pos := 0
+ for _, entry := range tree.Entries {
+ mode := strconv.FormatUint(uint64(entry.Mode), 8)
+ pos += copy(body[pos:], mode)
+ body[pos] = ' '
+ pos++
+ pos += copy(body[pos:], entry.Name)
+ body[pos] = 0
+ pos++
+ id := entry.ID.Bytes()
+ pos += copy(body[pos:], id)
+ }
+
+ return body
+}
+
+// Serialize renders the raw object (header + body).
+func (tree *Tree) Serialize() ([]byte, error) {
+ body := tree.serialize()
+ header, err := headerForType(TypeTree, body)
+ if err != nil {
+ return nil, err
+ }
+ raw := make([]byte, len(header)+len(body))
+ copy(raw, header)
+ copy(raw[len(header):], body)
+ return raw, nil
+}