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
+}
--
⑨