diff options
| author | 2026-05-24 07:47:05 +0000 | |
|---|---|---|
| committer | 2026-05-24 07:47:05 +0000 | |
| commit | cad22e69bc0af248b1ed80a19f2f6bbef4b00e18 (patch) | |
| tree | 5978717863f8ab47fdfad4a041b5759d4d9475bc | |
| parent | object/blob: Fix naming (diff) | |
| signature | No signature | |
object/commit: Basic implementation
| -rw-r--r-- | object/commit/append.go | 70 | ||||
| -rw-r--r-- | object/commit/append_test.go | 34 | ||||
| -rw-r--r-- | object/commit/commit.go | 19 | ||||
| -rw-r--r-- | object/commit/doc.go | 8 | ||||
| -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/type.go | 12 |
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 +} |
