aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-05-24 07:47:05 +0000
committerGravatar Runxi Yu2026-05-24 07:47:05 +0000
commitcad22e69bc0af248b1ed80a19f2f6bbef4b00e18 (patch)
tree5978717863f8ab47fdfad4a041b5759d4d9475bc
parentobject/blob: Fix naming (diff)
signatureNo signature
object/commit: Basic implementation
-rw-r--r--object/commit/append.go70
-rw-r--r--object/commit/append_test.go34
-rw-r--r--object/commit/commit.go19
-rw-r--r--object/commit/doc.go8
-rw-r--r--object/commit/extraheader.go7
-rw-r--r--object/commit/parse.go94
-rw-r--r--object/commit/parse_test.go91
-rw-r--r--object/commit/type.go12
8 files changed, 335 insertions, 0 deletions
diff --git a/object/commit/append.go b/object/commit/append.go
new file mode 100644
index 00000000..8da33383
--- /dev/null
+++ b/object/commit/append.go
@@ -0,0 +1,70 @@
+package commit
+
+import (
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object/header"
+ "codeberg.org/lindenii/furgit/object/typ"
+)
+
+// AppendWithoutHeader renders the raw commit body bytes.
+func (commit *Commit) AppendWithoutHeader(dst []byte) ([]byte, error) {
+ if commit.Tree.Algorithm().Size() == 0 {
+ return dst, errors.New("object: commit: missing tree id")
+ }
+
+ dst = fmt.Appendf(dst, "tree %s\n", commit.Tree.String())
+
+ for _, parent := range commit.Parents {
+ dst = fmt.Appendf(dst, "parent %s\n", parent.String())
+ }
+
+ dst = append(dst, []byte("author ")...)
+ dst = commit.Author.Append(dst)
+ dst = append(dst, byte('\n'))
+
+ dst = append(dst, []byte("comitter ")...)
+ dst = commit.Committer.Append(dst)
+ dst = append(dst, byte('\n'))
+
+ if commit.ChangeID != "" {
+ dst = append(dst, []byte("change-id ")...)
+ dst = append(dst, commit.ChangeID...)
+ dst = append(dst, byte('\n'))
+ }
+
+ for _, h := range commit.ExtraHeaders {
+ if h.Key == "" {
+ return dst, errors.New("object: commit: extra header has empty key")
+ }
+
+ 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, commit.Message...)
+
+ return dst, nil
+}
+
+// AppendWithHeader renders the raw object (header + body).
+func (commit *Commit) AppendWithHeader(dst []byte) ([]byte, error) {
+ dst, err := commit.AppendWithoutHeader(dst)
+ if err != nil {
+ return dst, err
+ }
+
+ // TODO: Try to not allocate?
+ body, err := commit.AppendWithoutHeader([]byte(nil))
+ if err != nil {
+ return dst, err
+ }
+
+ dst = header.Append(dst, typ.TypeCommit, uint64(len(body)))
+
+ return append(dst, body...), nil
+}
diff --git a/object/commit/append_test.go b/object/commit/append_test.go
new file mode 100644
index 00000000..46b53c69
--- /dev/null
+++ b/object/commit/append_test.go
@@ -0,0 +1,34 @@
+package commit_test
+
+import (
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/object/commit"
+ "codeberg.org/lindenii/furgit/object/id"
+)
+
+func TestCommitSerialize(t *testing.T) {
+ t.Parallel()
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo id.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, commitID := testRepo.MakeCommit(t, "subject\n\nbody")
+
+ rawBody := testRepo.CatFile(t, "commit", commitID)
+
+ parsed, err := commit.Parse(rawBody, algo)
+ if err != nil {
+ t.Fatalf("ParseCommit: %v", err)
+ }
+
+ rawObj, err := parsed.AppendWithHeader([]byte(nil))
+ if err != nil {
+ t.Fatalf("BytesWithHeader: %v", err)
+ }
+
+ gotID := algo.Sum(rawObj)
+ if gotID != commitID {
+ t.Fatalf("commit id mismatch: got %s want %s", gotID, commitID)
+ }
+ })
+}
diff --git a/object/commit/commit.go b/object/commit/commit.go
new file mode 100644
index 00000000..e89b5368
--- /dev/null
+++ b/object/commit/commit.go
@@ -0,0 +1,19 @@
+package commit
+
+import (
+ "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/signature"
+)
+
+// Commit represents a fully materialized Git commit object.
+//
+// Labels: MT-Unsafe.
+type Commit struct {
+ Tree id.ObjectID
+ Parents []id.ObjectID
+ Author signature.Signature
+ Committer signature.Signature
+ Message []byte
+ ChangeID string
+ ExtraHeaders []ExtraHeader
+}
diff --git a/object/commit/doc.go b/object/commit/doc.go
new file mode 100644
index 00000000..51075f9b
--- /dev/null
+++ b/object/commit/doc.go
@@ -0,0 +1,8 @@
+// Package commit provides parsed commit objects and commit serialization.
+//
+// It parses commits 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 [codeberg.org/lindenii/furgit/object/signed/commit].
+package commit
diff --git a/object/commit/extraheader.go b/object/commit/extraheader.go
new file mode 100644
index 00000000..79d4f9cc
--- /dev/null
+++ b/object/commit/extraheader.go
@@ -0,0 +1,7 @@
+package commit
+
+// ExtraHeader represents an extra header in a Git object.
+type ExtraHeader struct {
+ Key string
+ Value []byte
+}
diff --git a/object/commit/parse.go b/object/commit/parse.go
new file mode 100644
index 00000000..d0257003
--- /dev/null
+++ b/object/commit/parse.go
@@ -0,0 +1,94 @@
+package commit
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/object/signature"
+)
+
+// Parse decodes a commit object body.
+func Parse(body []byte, algo id.Algorithm) (*Commit, error) {
+ 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 := id.FromHex(algo, string(value))
+ if err != nil {
+ return nil, fmt.Errorf("object: commit: tree: %w", err)
+ }
+
+ c.Tree = id
+ case "parent":
+ id, err := id.FromHex(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 := signature.Parse(value)
+ if err != nil {
+ return nil, fmt.Errorf("object: commit: author: %w", err)
+ }
+
+ c.Author = *idt
+ case "committer":
+ idt, err := signature.Parse(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, errors.New("object: commit: parser position out of bounds")
+ }
+
+ c.Message = append([]byte(nil), body[i:]...)
+
+ return c, nil
+}
diff --git a/object/commit/parse_test.go b/object/commit/parse_test.go
new file mode 100644
index 00000000..ad2c7aed
--- /dev/null
+++ b/object/commit/parse_test.go
@@ -0,0 +1,91 @@
+package commit_test
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/object/commit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+func TestCommitParseFromGit(t *testing.T) {
+ t.Parallel()
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, treeID, commitID := testRepo.MakeCommit(t, "subject\n\nbody")
+
+ rawBody := testRepo.CatFile(t, "commit", commitID)
+
+ parsed, err := commit.Parse(rawBody, algo)
+ if err != nil {
+ t.Fatalf("ParseCommit: %v", err)
+ }
+
+ if parsed.Tree != treeID {
+ t.Fatalf("tree id mismatch: got %s want %s", parsed.Tree, treeID)
+ }
+
+ if len(parsed.Parents) != 0 {
+ t.Fatalf("parent count = %d, want 0", len(parsed.Parents))
+ }
+
+ if !bytes.Equal(parsed.Author.Name, []byte("Test Author")) {
+ t.Fatalf("author name = %q, want %q", parsed.Author.Name, "Test Author")
+ }
+
+ if !bytes.Equal(parsed.Committer.Name, []byte("Test Committer")) {
+ t.Fatalf("committer name = %q, want %q", parsed.Committer.Name, "Test Committer")
+ }
+
+ if !bytes.Contains(parsed.Message, []byte("subject")) {
+ t.Fatalf("commit message missing subject: %q", parsed.Message)
+ }
+ })
+}
+
+func TestCommitParseMultipleParents(t *testing.T) {
+ t.Parallel()
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+
+ _, treeID := testRepo.MakeSingleFileTree(t, "file.txt", []byte("merge-content\n"))
+ parent1 := testRepo.CommitTree(t, treeID, "parent-one")
+ parent2 := testRepo.CommitTree(t, treeID, "parent-two", parent1)
+
+ rawCommit := fmt.Sprintf(
+ "tree %s\nparent %s\nparent %s\nauthor Test Author <test@example.org> 1234567890 +0000\ncommitter Test Committer <committer@example.org> 1234567890 +0000\n\nMerge commit\n",
+ treeID,
+ parent1,
+ parent2,
+ )
+ mergeID := testRepo.HashObject(t, "commit", []byte(rawCommit))
+ rawBody := testRepo.CatFile(t, "commit", mergeID)
+
+ parsed, err := commit.Parse(rawBody, algo)
+ if err != nil {
+ t.Fatalf("ParseCommit(merge): %v", err)
+ }
+
+ if parsed.Tree != treeID {
+ t.Fatalf("merge tree = %s, want %s", parsed.Tree, treeID)
+ }
+
+ if len(parsed.Parents) != 2 {
+ t.Fatalf("merge parent count = %d, want 2", len(parsed.Parents))
+ }
+
+ if parsed.Parents[0] != parent1 {
+ t.Fatalf("merge parent[0] = %s, want %s", parsed.Parents[0], parent1)
+ }
+
+ if parsed.Parents[1] != parent2 {
+ t.Fatalf("merge parent[1] = %s, want %s", parsed.Parents[1], parent2)
+ }
+
+ if !bytes.Equal(parsed.Message, []byte("Merge commit\n")) {
+ t.Fatalf("merge message = %q, want %q", parsed.Message, "Merge commit\n")
+ }
+ })
+}
diff --git a/object/commit/type.go b/object/commit/type.go
new file mode 100644
index 00000000..065b7174
--- /dev/null
+++ b/object/commit/type.go
@@ -0,0 +1,12 @@
+package commit
+
+import (
+ "codeberg.org/lindenii/furgit/object/typ"
+)
+
+// ObjectType returns TypeCommit.
+func (commit *Commit) ObjectType() typ.Type {
+ _ = commit
+
+ return typ.TypeCommit
+}