shithub: furgit

Download patch

ref: b5a545a3d883026d61beac5556fec2a45e9ec3d3
parent: 05e07f6c6aca1662c33359f41c66e6f9b6eb935a
author: Runxi Yu <me@runxiyu.org>
date: Fri Feb 20 16:20:35 EST 2026

object: Add basic object code

--- /dev/null
+++ b/object/blob.go
@@ -1,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
+}
--- /dev/null
+++ b/object/blob_parse.go
@@ -1,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
+}
--- /dev/null
+++ b/object/blob_serialize.go
@@ -1,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
+}
--- /dev/null
+++ b/object/commit.go
@@ -1,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
+}
--- /dev/null
+++ b/object/commit_parse.go
@@ -1,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
+}
--- /dev/null
+++ b/object/commit_serialize.go
@@ -1,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
+}
--- /dev/null
+++ b/object/extraheader.go
@@ -1,0 +1,7 @@
+package object
+
+// ExtraHeader represents an extra header in a Git object.
+type ExtraHeader struct {
+	Key   string
+	Value []byte
+}
--- /dev/null
+++ b/object/ident.go
@@ -1,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)
+}
--- /dev/null
+++ b/object/object.go
@@ -1,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
+}
--- /dev/null
+++ b/object/tag.go
@@ -1,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
+}
--- /dev/null
+++ b/object/tag_parse.go
@@ -1,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
+}
--- /dev/null
+++ b/object/tag_serialize.go
@@ -1,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
+}
--- /dev/null
+++ b/object/tree.go
@@ -1,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
+}
--- /dev/null
+++ b/object/tree_parse.go
@@ -1,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
+}
--- /dev/null
+++ b/object/tree_serialize.go
@@ -1,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
+}
--