diff options
Diffstat (limited to 'object/commit')
| -rw-r--r-- | object/commit/commit.go | 18 | ||||
| -rw-r--r-- | object/commit/extraheader.go | 7 | ||||
| -rw-r--r-- | object/commit/parse.go | 94 | ||||
| -rw-r--r-- | object/commit/parse_test.go | 91 | ||||
| -rw-r--r-- | object/commit/serialize.go | 84 | ||||
| -rw-r--r-- | object/commit/serialize_test.go | 34 | ||||
| -rw-r--r-- | object/commit/type.go | 10 |
7 files changed, 338 insertions, 0 deletions
diff --git a/object/commit/commit.go b/object/commit/commit.go new file mode 100644 index 00000000..e2e087f5 --- /dev/null +++ b/object/commit/commit.go @@ -0,0 +1,18 @@ +// Package commit provides representations, parsers, and serializers for commit objects. +package commit + +import ( + objectid "codeberg.org/lindenii/furgit/object/id" + objectsignature "codeberg.org/lindenii/furgit/object/signature" +) + +// Commit represents a Git commit object. +type Commit struct { + Tree objectid.ObjectID + Parents []objectid.ObjectID + Author objectsignature.Signature + Committer objectsignature.Signature + Message []byte + ChangeID string + ExtraHeaders []ExtraHeader +} 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..9dcc930d --- /dev/null +++ b/object/commit/parse.go @@ -0,0 +1,94 @@ +package commit + +import ( + "bytes" + "errors" + "fmt" + + objectid "codeberg.org/lindenii/furgit/object/id" + objectsignature "codeberg.org/lindenii/furgit/object/signature" +) + +// Parse decodes a commit object body. +func Parse(body []byte, algo objectid.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 := objectid.ParseHex(algo, string(value)) + if err != nil { + return nil, fmt.Errorf("object: commit: tree: %w", err) + } + + c.Tree = id + case "parent": + id, err := objectid.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 := objectsignature.Parse(value) + if err != nil { + return nil, fmt.Errorf("object: commit: author: %w", err) + } + + c.Author = *idt + case "committer": + idt, err := objectsignature.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/serialize.go b/object/commit/serialize.go new file mode 100644 index 00000000..721cacf6 --- /dev/null +++ b/object/commit/serialize.go @@ -0,0 +1,84 @@ +package commit + +import ( + "bytes" + "errors" + "fmt" + + objectheader "codeberg.org/lindenii/furgit/object/header" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +// SerializeWithoutHeader renders the raw commit body bytes. +func (commit *Commit) SerializeWithoutHeader() ([]byte, error) { + var buf bytes.Buffer + + if commit.Tree.Size() == 0 { + return nil, errors.New("object: commit: missing tree id") + } + + 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, errors.New("object: commit: extra header has empty key") + } + + buf.WriteString(h.Key) + buf.WriteByte(' ') + buf.Write(h.Value) + buf.WriteByte('\n') + } + + buf.WriteByte('\n') + buf.Write(commit.Message) + + return buf.Bytes(), nil +} + +// SerializeWithHeader renders the raw object (header + body). +func (commit *Commit) SerializeWithHeader() ([]byte, error) { + body, err := commit.SerializeWithoutHeader() + if err != nil { + return nil, err + } + + header, ok := objectheader.Encode(objecttype.TypeCommit, int64(len(body))) + if !ok { + return nil, errors.New("object: commit: failed to encode object header") + } + + raw := make([]byte, len(header)+len(body)) + copy(raw, header) + copy(raw[len(header):], body) + + return raw, nil +} diff --git a/object/commit/serialize_test.go b/object/commit/serialize_test.go new file mode 100644 index 00000000..e58a8078 --- /dev/null +++ b/object/commit/serialize_test.go @@ -0,0 +1,34 @@ +package commit_test + +import ( + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/object/commit" + objectid "codeberg.org/lindenii/furgit/object/id" +) + +func TestCommitSerialize(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}) + _, _, 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.SerializeWithHeader() + if err != nil { + t.Fatalf("SerializeWithHeader: %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/type.go b/object/commit/type.go new file mode 100644 index 00000000..b8aa11e8 --- /dev/null +++ b/object/commit/type.go @@ -0,0 +1,10 @@ +package commit + +import objecttype "codeberg.org/lindenii/furgit/object/type" + +// ObjectType returns TypeCommit. +func (commit *Commit) ObjectType() objecttype.Type { + _ = commit + + return objecttype.TypeCommit +} |
