From 7847657e0820af98120031f719b8ede635ad8c07 Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Wed, 25 Mar 2026 14:49:17 +0000 Subject: object: Split each object type into its own package --- object/blob.go | 19 ---- object/blob/blob.go | 11 +++ object/blob/parse.go | 6 ++ object/blob/parse_test.go | 30 +++++++ object/blob/serialize.go | 32 +++++++ object/blob/serialize_test.go | 30 +++++++ object/blob/test.go | 10 +++ object/blob_parse.go | 6 -- object/blob_parse_test.go | 30 ------- object/blob_serialize.go | 32 ------- object/blob_serialize_test.go | 30 ------- object/commit.go | 24 ------ object/commit/commit.go | 18 ++++ object/commit/extraheader.go | 7 ++ object/commit/parse.go | 94 ++++++++++++++++++++ object/commit/parse_test.go | 91 +++++++++++++++++++ object/commit/serialize.go | 84 ++++++++++++++++++ object/commit/serialize_test.go | 34 ++++++++ object/commit/type.go | 10 +++ object/commit_parse.go | 93 -------------------- object/commit_parse_test.go | 91 ------------------- object/commit_serialize.go | 84 ------------------ object/commit_serialize_test.go | 34 -------- object/extraheader.go | 7 -- object/ident.go | 140 ------------------------------ object/object.go | 5 +- object/parse.go | 12 ++- object/resolve/exact_blob.go | 6 +- object/resolve/exact_commit.go | 6 +- object/resolve/exact_tag.go | 6 +- object/resolve/exact_tree.go | 6 +- object/resolve/path.go | 20 ++--- object/resolve/peel_to_blob.go | 9 +- object/resolve/peel_to_commit.go | 9 +- object/resolve/peel_to_tag.go | 4 +- object/resolve/peel_to_tree.go | 12 +-- object/resolve/resolver.go | 2 +- object/resolve/treefs.go | 4 +- object/resolve/treefs_entry.go | 12 +-- object/resolve/treefs_info.go | 16 ++-- object/resolve/treefs_open.go | 4 +- object/resolve/treefs_readfile.go | 4 +- object/resolve/treefs_stat.go | 2 +- object/resolve/treefs_test.go | 10 +-- object/signature/parse.go | 97 +++++++++++++++++++++ object/signature/serialize.go | 33 +++++++ object/signature/signature.go | 10 +++ object/signature/when.go | 10 +++ object/stored/stored.go | 2 +- object/storer/chain/bytes.go | 2 +- object/storer/chain/chain.go | 4 +- object/storer/chain/header.go | 2 +- object/storer/chain/new.go | 2 +- object/storer/chain/reader.go | 2 +- object/storer/chain/size.go | 2 +- object/storer/loose/paths.go | 2 +- object/storer/loose/read_test.go | 2 +- object/storer/memory/read_bytes.go | 2 +- object/storer/memory/read_header.go | 2 +- object/storer/mix/bytes.go | 2 +- object/storer/mix/header.go | 2 +- object/storer/mix/mix.go | 2 +- object/storer/mix/mru.go | 2 +- object/storer/mix/new.go | 2 +- object/storer/mix/reader.go | 2 +- object/storer/mix/refresh.go | 2 +- object/storer/mix/size.go | 2 +- object/storer/packed/read_test.go | 2 +- object/storer/packed/store.go | 2 +- object/storer/packed/store_lookup.go | 2 +- object/tag.go | 22 ----- object/tag/parse.go | 89 +++++++++++++++++++ object/tag/parse_test.go | 47 ++++++++++ object/tag/serialize.go | 68 +++++++++++++++ object/tag/serialize_test.go | 35 ++++++++ object/tag/tag.go | 17 ++++ object/tag/type.go | 10 +++ object/tag_parse.go | 88 ------------------- object/tag_parse_test.go | 47 ---------- object/tag_serialize.go | 68 --------------- object/tag_serialize_test.go | 35 -------- object/tree.go | 163 ----------------------------------- object/tree/entry.go | 39 +++++++++ object/tree/helpers_test.go | 114 ++++++++++++++++++++++++ object/tree/insert.go | 23 +++++ object/tree/lookup.go | 14 +++ object/tree/mode.go | 12 +++ object/tree/name.go | 51 +++++++++++ object/tree/parse.go | 58 +++++++++++++ object/tree/parse_test.go | 76 ++++++++++++++++ object/tree/remove.go | 24 ++++++ object/tree/serialize.go | 55 ++++++++++++ object/tree/serialize_test.go | 73 ++++++++++++++++ object/tree/tree.go | 7 ++ object/tree/type.go | 10 +++ object/tree_helpers_test.go | 114 ------------------------ object/tree_parse.go | 58 ------------- object/tree_parse_test.go | 76 ---------------- object/tree_serialize.go | 55 ------------ object/tree_serialize_test.go | 73 ---------------- 100 files changed, 1529 insertions(+), 1484 deletions(-) delete mode 100644 object/blob.go create mode 100644 object/blob/blob.go create mode 100644 object/blob/parse.go create mode 100644 object/blob/parse_test.go create mode 100644 object/blob/serialize.go create mode 100644 object/blob/serialize_test.go create mode 100644 object/blob/test.go delete mode 100644 object/blob_parse.go delete mode 100644 object/blob_parse_test.go delete mode 100644 object/blob_serialize.go delete mode 100644 object/blob_serialize_test.go delete mode 100644 object/commit.go create mode 100644 object/commit/commit.go create mode 100644 object/commit/extraheader.go create mode 100644 object/commit/parse.go create mode 100644 object/commit/parse_test.go create mode 100644 object/commit/serialize.go create mode 100644 object/commit/serialize_test.go create mode 100644 object/commit/type.go delete mode 100644 object/commit_parse.go delete mode 100644 object/commit_parse_test.go delete mode 100644 object/commit_serialize.go delete mode 100644 object/commit_serialize_test.go delete mode 100644 object/extraheader.go delete mode 100644 object/ident.go create mode 100644 object/signature/parse.go create mode 100644 object/signature/serialize.go create mode 100644 object/signature/signature.go create mode 100644 object/signature/when.go delete mode 100644 object/tag.go create mode 100644 object/tag/parse.go create mode 100644 object/tag/parse_test.go create mode 100644 object/tag/serialize.go create mode 100644 object/tag/serialize_test.go create mode 100644 object/tag/tag.go create mode 100644 object/tag/type.go delete mode 100644 object/tag_parse.go delete mode 100644 object/tag_parse_test.go delete mode 100644 object/tag_serialize.go delete mode 100644 object/tag_serialize_test.go delete mode 100644 object/tree.go create mode 100644 object/tree/entry.go create mode 100644 object/tree/helpers_test.go create mode 100644 object/tree/insert.go create mode 100644 object/tree/lookup.go create mode 100644 object/tree/mode.go create mode 100644 object/tree/name.go create mode 100644 object/tree/parse.go create mode 100644 object/tree/parse_test.go create mode 100644 object/tree/remove.go create mode 100644 object/tree/serialize.go create mode 100644 object/tree/serialize_test.go create mode 100644 object/tree/tree.go create mode 100644 object/tree/type.go delete mode 100644 object/tree_helpers_test.go delete mode 100644 object/tree_parse.go delete mode 100644 object/tree_parse_test.go delete mode 100644 object/tree_serialize.go delete mode 100644 object/tree_serialize_test.go (limited to 'object') diff --git a/object/blob.go b/object/blob.go deleted file mode 100644 index 1c827190..00000000 --- a/object/blob.go +++ /dev/null @@ -1,19 +0,0 @@ -package object - -import objecttype "codeberg.org/lindenii/furgit/object/type" - -// Blob represents a Git blob object. -// -// This Blob object is fully materialized in memory. -// Consider using objectstorer/Store.ReadReaderContent, -// or appropriate streaming write APIs. -type Blob struct { - Data []byte -} - -// ObjectType returns TypeBlob. -func (blob *Blob) ObjectType() objecttype.Type { - _ = blob - - return objecttype.TypeBlob -} diff --git a/object/blob/blob.go b/object/blob/blob.go new file mode 100644 index 00000000..977121fb --- /dev/null +++ b/object/blob/blob.go @@ -0,0 +1,11 @@ +// Package blob provides representations, parsers, and serializers for blob objects. +package blob + +// Blob represents a Git blob object. +// +// This Blob object is fully materialized in memory. +// Consider using objectstorer/Store.ReadReaderContent, +// or appropriate streaming write APIs. +type Blob struct { + Data []byte +} diff --git a/object/blob/parse.go b/object/blob/parse.go new file mode 100644 index 00000000..faee9e46 --- /dev/null +++ b/object/blob/parse.go @@ -0,0 +1,6 @@ +package blob + +// Parse decodes a blob object body. +func Parse(body []byte) (*Blob, error) { + return &Blob{Data: append([]byte(nil), body...)}, nil +} diff --git a/object/blob/parse_test.go b/object/blob/parse_test.go new file mode 100644 index 00000000..09d5d5d0 --- /dev/null +++ b/object/blob/parse_test.go @@ -0,0 +1,30 @@ +package blob_test + +import ( + "bytes" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/object/blob" + objectid "codeberg.org/lindenii/furgit/object/id" +) + +func TestBlobParseFromGit(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}) + body := []byte("hello\nblob\n") + blobID := testRepo.HashObject(t, "blob", body) + + rawBody := testRepo.CatFile(t, "blob", blobID) + + parsed, err := blob.Parse(rawBody) + if err != nil { + t.Fatalf("ParseBlob: %v", err) + } + + if !bytes.Equal(parsed.Data, body) { + t.Fatalf("blob body mismatch") + } + }) +} diff --git a/object/blob/serialize.go b/object/blob/serialize.go new file mode 100644 index 00000000..80cce8dc --- /dev/null +++ b/object/blob/serialize.go @@ -0,0 +1,32 @@ +package blob + +import ( + "errors" + + objectheader "codeberg.org/lindenii/furgit/object/header" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +// SerializeWithoutHeader renders the raw blob body bytes. +func (blob *Blob) SerializeWithoutHeader() ([]byte, error) { + return append([]byte(nil), blob.Data...), nil +} + +// SerializeWithHeader renders the raw object (header + body). +func (blob *Blob) SerializeWithHeader() ([]byte, error) { + body, err := blob.SerializeWithoutHeader() + if err != nil { + return nil, err + } + + header, ok := objectheader.Encode(objecttype.TypeBlob, int64(len(body))) + if !ok { + return nil, errors.New("object: blob: 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/blob/serialize_test.go b/object/blob/serialize_test.go new file mode 100644 index 00000000..4292abad --- /dev/null +++ b/object/blob/serialize_test.go @@ -0,0 +1,30 @@ +package blob_test + +import ( + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/object/blob" + objectid "codeberg.org/lindenii/furgit/object/id" +) + +func TestBlobSerialize(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}) + body := []byte("hello\nblob\n") + wantID := testRepo.HashObject(t, "blob", body) + + obj := &blob.Blob{Data: body} + + rawObj, err := obj.SerializeWithHeader() + if err != nil { + t.Fatalf("SerializeWithHeader: %v", err) + } + + gotID := algo.Sum(rawObj) + if gotID != wantID { + t.Fatalf("object id mismatch: got %s want %s", gotID, wantID) + } + }) +} diff --git a/object/blob/test.go b/object/blob/test.go new file mode 100644 index 00000000..9e538219 --- /dev/null +++ b/object/blob/test.go @@ -0,0 +1,10 @@ +package blob + +import objecttype "codeberg.org/lindenii/furgit/object/type" + +// ObjectType returns TypeBlob. +func (blob *Blob) ObjectType() objecttype.Type { + _ = blob + + return objecttype.TypeBlob +} diff --git a/object/blob_parse.go b/object/blob_parse.go deleted file mode 100644 index 61aacfac..00000000 --- a/object/blob_parse.go +++ /dev/null @@ -1,6 +0,0 @@ -package object - -// ParseBlob decodes a blob object body. -func ParseBlob(body []byte) (*Blob, error) { - return &Blob{Data: append([]byte(nil), body...)}, nil -} diff --git a/object/blob_parse_test.go b/object/blob_parse_test.go deleted file mode 100644 index eb8f2f56..00000000 --- a/object/blob_parse_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package object_test - -import ( - "bytes" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" - objectid "codeberg.org/lindenii/furgit/object/id" -) - -func TestBlobParseFromGit(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}) - body := []byte("hello\nblob\n") - blobID := testRepo.HashObject(t, "blob", body) - - rawBody := testRepo.CatFile(t, "blob", blobID) - - blob, err := object.ParseBlob(rawBody) - if err != nil { - t.Fatalf("ParseBlob: %v", err) - } - - if !bytes.Equal(blob.Data, body) { - t.Fatalf("blob body mismatch") - } - }) -} diff --git a/object/blob_serialize.go b/object/blob_serialize.go deleted file mode 100644 index 2acc4c11..00000000 --- a/object/blob_serialize.go +++ /dev/null @@ -1,32 +0,0 @@ -package object - -import ( - "errors" - - objectheader "codeberg.org/lindenii/furgit/object/header" - objecttype "codeberg.org/lindenii/furgit/object/type" -) - -// SerializeWithoutHeader renders the raw blob body bytes. -func (blob *Blob) SerializeWithoutHeader() ([]byte, error) { - return append([]byte(nil), blob.Data...), nil -} - -// SerializeWithHeader renders the raw object (header + body). -func (blob *Blob) SerializeWithHeader() ([]byte, error) { - body, err := blob.SerializeWithoutHeader() - if err != nil { - return nil, err - } - - header, ok := objectheader.Encode(objecttype.TypeBlob, int64(len(body))) - if !ok { - return nil, errors.New("object: blob: 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/blob_serialize_test.go b/object/blob_serialize_test.go deleted file mode 100644 index 704811ca..00000000 --- a/object/blob_serialize_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package object_test - -import ( - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" - objectid "codeberg.org/lindenii/furgit/object/id" -) - -func TestBlobSerialize(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}) - body := []byte("hello\nblob\n") - wantID := testRepo.HashObject(t, "blob", body) - - blob := &object.Blob{Data: body} - - rawObj, err := blob.SerializeWithHeader() - if err != nil { - t.Fatalf("SerializeWithHeader: %v", err) - } - - gotID := algo.Sum(rawObj) - if gotID != wantID { - t.Fatalf("object id mismatch: got %s want %s", gotID, wantID) - } - }) -} diff --git a/object/commit.go b/object/commit.go deleted file mode 100644 index f7b0d676..00000000 --- a/object/commit.go +++ /dev/null @@ -1,24 +0,0 @@ -package object - -import ( - objectid "codeberg.org/lindenii/furgit/object/id" - objecttype "codeberg.org/lindenii/furgit/object/type" -) - -// Commit represents a Git commit object. -type Commit struct { - Tree objectid.ObjectID - Parents []objectid.ObjectID - Author Signature - Committer Signature - Message []byte - ChangeID string - ExtraHeaders []ExtraHeader -} - -// ObjectType returns TypeCommit. -func (commit *Commit) ObjectType() objecttype.Type { - _ = commit - - return objecttype.TypeCommit -} 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 1234567890 +0000\ncommitter Test Committer 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 +} diff --git a/object/commit_parse.go b/object/commit_parse.go deleted file mode 100644 index 6578d523..00000000 --- a/object/commit_parse.go +++ /dev/null @@ -1,93 +0,0 @@ -package object - -import ( - "bytes" - "errors" - "fmt" - - objectid "codeberg.org/lindenii/furgit/object/id" -) - -// ParseCommit decodes a commit object body. -func ParseCommit(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 := ParseSignature(value) - if err != nil { - return nil, fmt.Errorf("object: commit: author: %w", err) - } - - c.Author = *idt - case "committer": - idt, err := ParseSignature(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 deleted file mode 100644 index fae2b4c1..00000000 --- a/object/commit_parse_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package object_test - -import ( - "bytes" - "fmt" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" - 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) - - commit, err := object.ParseCommit(rawBody, algo) - if err != nil { - t.Fatalf("ParseCommit: %v", err) - } - - if commit.Tree != treeID { - t.Fatalf("tree id mismatch: got %s want %s", commit.Tree, treeID) - } - - if len(commit.Parents) != 0 { - t.Fatalf("parent count = %d, want 0", len(commit.Parents)) - } - - if !bytes.Equal(commit.Author.Name, []byte("Test Author")) { - t.Fatalf("author name = %q, want %q", commit.Author.Name, "Test Author") - } - - if !bytes.Equal(commit.Committer.Name, []byte("Test Committer")) { - t.Fatalf("committer name = %q, want %q", commit.Committer.Name, "Test Committer") - } - - if !bytes.Contains(commit.Message, []byte("subject")) { - t.Fatalf("commit message missing subject: %q", commit.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 1234567890 +0000\ncommitter Test Committer 1234567890 +0000\n\nMerge commit\n", - treeID, - parent1, - parent2, - ) - mergeID := testRepo.HashObject(t, "commit", []byte(rawCommit)) - rawBody := testRepo.CatFile(t, "commit", mergeID) - - commit, err := object.ParseCommit(rawBody, algo) - if err != nil { - t.Fatalf("ParseCommit(merge): %v", err) - } - - if commit.Tree != treeID { - t.Fatalf("merge tree = %s, want %s", commit.Tree, treeID) - } - - if len(commit.Parents) != 2 { - t.Fatalf("merge parent count = %d, want 2", len(commit.Parents)) - } - - if commit.Parents[0] != parent1 { - t.Fatalf("merge parent[0] = %s, want %s", commit.Parents[0], parent1) - } - - if commit.Parents[1] != parent2 { - t.Fatalf("merge parent[1] = %s, want %s", commit.Parents[1], parent2) - } - - if !bytes.Equal(commit.Message, []byte("Merge commit\n")) { - t.Fatalf("merge message = %q, want %q", commit.Message, "Merge commit\n") - } - }) -} diff --git a/object/commit_serialize.go b/object/commit_serialize.go deleted file mode 100644 index ed81b1d6..00000000 --- a/object/commit_serialize.go +++ /dev/null @@ -1,84 +0,0 @@ -package object - -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 deleted file mode 100644 index cff47b40..00000000 --- a/object/commit_serialize_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package object_test - -import ( - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" - 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) - - commit, err := object.ParseCommit(rawBody, algo) - if err != nil { - t.Fatalf("ParseCommit: %v", err) - } - - rawObj, err := commit.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/extraheader.go b/object/extraheader.go deleted file mode 100644 index 4ad1ec09..00000000 --- a/object/extraheader.go +++ /dev/null @@ -1,7 +0,0 @@ -package object - -// ExtraHeader represents an extra header in a Git object. -type ExtraHeader struct { - Key string - Value []byte -} diff --git a/object/ident.go b/object/ident.go deleted file mode 100644 index 049b0c01..00000000 --- a/object/ident.go +++ /dev/null @@ -1,140 +0,0 @@ -package object - -import ( - "bytes" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "codeberg.org/lindenii/furgit/internal/intconv" -) - -// Signature represents a Git signature (author/committer/tagger). -type Signature struct { - Name []byte - Email []byte - WhenUnix int64 - OffsetMinutes int32 -} - -// ParseSignature parses a canonical Git signature line: -// "Name 123456789 +0000". -func ParseSignature(line []byte) (*Signature, error) { - lt := bytes.IndexByte(line, '<') - if lt < 0 { - return nil, errors.New("object: signature: missing opening <") - } - - gtRel := bytes.IndexByte(line[lt+1:], '>') - if gtRel < 0 { - return nil, errors.New("object: signature: 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: signature: missing timestamp separator") - } - - rest = rest[1:] - - before, after, ok := bytes.Cut(rest, []byte{' '}) - if !ok { - return nil, errors.New("object: signature: missing timezone separator") - } - - when, err := strconv.ParseInt(string(before), 10, 64) - if err != nil { - return nil, fmt.Errorf("object: signature: invalid timestamp: %w", err) - } - - tz := after - if len(tz) < 5 { - return nil, errors.New("object: signature: invalid timezone encoding") - } - - sign := 1 - - switch tz[0] { - case '-': - sign = -1 - case '+': - default: - return nil, errors.New("object: signature: invalid timezone sign") - } - - hh, err := strconv.Atoi(string(tz[1:3])) - if err != nil { - return nil, fmt.Errorf("object: signature: invalid timezone hours: %w", err) - } - - mm, err := strconv.Atoi(string(tz[3:5])) - if err != nil { - return nil, fmt.Errorf("object: signature: invalid timezone minutes: %w", err) - } - - if hh < 0 || hh > 23 { - return nil, errors.New("object: signature: invalid timezone hours range") - } - - if mm < 0 || mm > 59 { - return nil, errors.New("object: signature: invalid timezone minutes range") - } - - total := int64(hh)*60 + int64(mm) - - offset, err := intconv.Int64ToInt32(total) - if err != nil { - return nil, errors.New("object: signature: timezone overflow") - } - - if sign < 0 { - offset = -offset - } - - return &Signature{ - Name: nameBytes, - Email: emailBytes, - WhenUnix: when, - OffsetMinutes: offset, - }, nil -} - -// Serialize renders the signature in canonical Git format. -func (signature Signature) Serialize() ([]byte, error) { - var b strings.Builder - b.Grow(len(signature.Name) + len(signature.Email) + 32) - b.Write(signature.Name) - b.WriteString(" <") - b.Write(signature.Email) - b.WriteString("> ") - b.WriteString(strconv.FormatInt(signature.WhenUnix, 10)) - b.WriteByte(' ') - - offset := signature.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 signature's timezone offset. -func (signature Signature) When() time.Time { - loc := time.FixedZone("git", int(signature.OffsetMinutes)*60) - - return time.Unix(signature.WhenUnix, 0).In(loc) -} diff --git a/object/object.go b/object/object.go index 70d418df..f2325211 100644 --- a/object/object.go +++ b/object/object.go @@ -1,10 +1,9 @@ -// Package object parses and serializes objects such as blob, tree, commit, and -// tag. +// Package object provides shared object interfaces. package object import objecttype "codeberg.org/lindenii/furgit/object/type" -// Object is a Git object that can serialize itself. +// Object is a Git object. type Object interface { ObjectType() objecttype.Type SerializeWithoutHeader() ([]byte, error) diff --git a/object/parse.go b/object/parse.go index cb75cb43..7cc01a7a 100644 --- a/object/parse.go +++ b/object/parse.go @@ -3,8 +3,12 @@ package object import ( "fmt" + "codeberg.org/lindenii/furgit/object/blob" + "codeberg.org/lindenii/furgit/object/commit" objectheader "codeberg.org/lindenii/furgit/object/header" objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/tag" + "codeberg.org/lindenii/furgit/object/tree" objecttype "codeberg.org/lindenii/furgit/object/type" ) @@ -31,13 +35,13 @@ func ParseObjectWithHeader(raw []byte, algo objectid.Algorithm) (Object, error) func ParseObjectWithoutHeader(ty objecttype.Type, body []byte, algo objectid.Algorithm) (Object, error) { switch ty { case objecttype.TypeBlob: - return ParseBlob(body) + return blob.Parse(body) case objecttype.TypeTree: - return ParseTree(body, algo) + return tree.Parse(body, algo) case objecttype.TypeCommit: - return ParseCommit(body, algo) + return commit.Parse(body, algo) case objecttype.TypeTag: - return ParseTag(body, algo) + return tag.Parse(body, algo) case objecttype.TypeInvalid, objecttype.TypeFuture, objecttype.TypeOfsDelta, objecttype.TypeRefDelta: return nil, fmt.Errorf("object: unsupported object type %d", ty) default: diff --git a/object/resolve/exact_blob.go b/object/resolve/exact_blob.go index 07501513..2cd8b298 100644 --- a/object/resolve/exact_blob.go +++ b/object/resolve/exact_blob.go @@ -3,19 +3,19 @@ package resolve import ( "fmt" - "codeberg.org/lindenii/furgit/object" + "codeberg.org/lindenii/furgit/object/blob" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/stored" ) // ExactBlob reads, parses, and wraps the blob at id. -func (r *Resolver) ExactBlob(id objectid.ObjectID) (*stored.Stored[*object.Blob], error) { +func (r *Resolver) ExactBlob(id objectid.ObjectID) (*stored.Stored[*blob.Blob], error) { parsed, err := r.parseObject(id) if err != nil { return nil, err } - blob, ok := parsed.(*object.Blob) + blob, ok := parsed.(*blob.Blob) if !ok { return nil, fmt.Errorf("object/resolve: expected blob object %s, got %v", id, parsed.ObjectType()) } diff --git a/object/resolve/exact_commit.go b/object/resolve/exact_commit.go index ba76baa2..e6b379aa 100644 --- a/object/resolve/exact_commit.go +++ b/object/resolve/exact_commit.go @@ -3,19 +3,19 @@ package resolve import ( "fmt" - "codeberg.org/lindenii/furgit/object" + "codeberg.org/lindenii/furgit/object/commit" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/stored" ) // ExactCommit reads, parses, and wraps the commit at id. -func (r *Resolver) ExactCommit(id objectid.ObjectID) (*stored.Stored[*object.Commit], error) { +func (r *Resolver) ExactCommit(id objectid.ObjectID) (*stored.Stored[*commit.Commit], error) { parsed, err := r.parseObject(id) if err != nil { return nil, err } - commit, ok := parsed.(*object.Commit) + commit, ok := parsed.(*commit.Commit) if !ok { return nil, fmt.Errorf("object/resolve: expected commit object %s, got %v", id, parsed.ObjectType()) } diff --git a/object/resolve/exact_tag.go b/object/resolve/exact_tag.go index 26bf2b11..8c5d22c9 100644 --- a/object/resolve/exact_tag.go +++ b/object/resolve/exact_tag.go @@ -3,19 +3,19 @@ package resolve import ( "fmt" - "codeberg.org/lindenii/furgit/object" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/stored" + "codeberg.org/lindenii/furgit/object/tag" ) // ExactTag reads, parses, and wraps the tag at id. -func (r *Resolver) ExactTag(id objectid.ObjectID) (*stored.Stored[*object.Tag], error) { +func (r *Resolver) ExactTag(id objectid.ObjectID) (*stored.Stored[*tag.Tag], error) { parsed, err := r.parseObject(id) if err != nil { return nil, err } - tag, ok := parsed.(*object.Tag) + tag, ok := parsed.(*tag.Tag) if !ok { return nil, fmt.Errorf("object/resolve: expected tag object %s, got %v", id, parsed.ObjectType()) } diff --git a/object/resolve/exact_tree.go b/object/resolve/exact_tree.go index aaf40236..de58ddb1 100644 --- a/object/resolve/exact_tree.go +++ b/object/resolve/exact_tree.go @@ -3,19 +3,19 @@ package resolve import ( "fmt" - "codeberg.org/lindenii/furgit/object" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/stored" + "codeberg.org/lindenii/furgit/object/tree" ) // ExactTree reads, parses, and wraps the tree at id. -func (r *Resolver) ExactTree(id objectid.ObjectID) (*stored.Stored[*object.Tree], error) { +func (r *Resolver) ExactTree(id objectid.ObjectID) (*stored.Stored[*tree.Tree], error) { parsed, err := r.parseObject(id) if err != nil { return nil, err } - tree, ok := parsed.(*object.Tree) + tree, ok := parsed.(*tree.Tree) if !ok { return nil, fmt.Errorf("object/resolve: expected tree object %s, got %v", id, parsed.ObjectType()) } diff --git a/object/resolve/path.go b/object/resolve/path.go index 1f865403..d11f3b48 100644 --- a/object/resolve/path.go +++ b/object/resolve/path.go @@ -3,8 +3,8 @@ package resolve import ( "fmt" - "codeberg.org/lindenii/furgit/object" objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/tree" ) // PathEmptyError indicates that Path received no segments. @@ -59,24 +59,24 @@ func (err *PathNotTreeError) Error() string { // // If your entry names are valid UTF-8 and uses / solely as segment separators, // it may be convenient to use TreeFS for an io/fs.FS-like interface. -func (r *Resolver) Path(root objectid.ObjectID, parts [][]byte) (object.TreeEntry, error) { +func (r *Resolver) Path(root objectid.ObjectID, parts [][]byte) (tree.TreeEntry, error) { if len(parts) == 0 { - return object.TreeEntry{}, &PathEmptyError{} + return tree.TreeEntry{}, &PathEmptyError{} } current, err := r.PeelToTree(root) if err != nil { - return object.TreeEntry{}, err + return tree.TreeEntry{}, err } for i, part := range parts { if len(part) == 0 { - return object.TreeEntry{}, &PathSegmentEmptyError{Index: i} + return tree.TreeEntry{}, &PathSegmentEmptyError{Index: i} } entry := current.Object().Entry(part) if entry == nil { - return object.TreeEntry{}, &PathNotFoundError{ + return tree.TreeEntry{}, &PathNotFoundError{ Index: i, Name: append([]byte(nil), part...), } @@ -86,8 +86,8 @@ func (r *Resolver) Path(root objectid.ObjectID, parts [][]byte) (object.TreeEntr return *entry, nil } - if entry.Mode != object.FileModeDir { - return object.TreeEntry{}, &PathNotTreeError{ + if entry.Mode != tree.FileModeDir { + return tree.TreeEntry{}, &PathNotTreeError{ Index: i, Name: append([]byte(nil), part...), } @@ -95,9 +95,9 @@ func (r *Resolver) Path(root objectid.ObjectID, parts [][]byte) (object.TreeEntr current, err = r.ExactTree(entry.ID) if err != nil { - return object.TreeEntry{}, err + return tree.TreeEntry{}, err } } - return object.TreeEntry{}, &PathNotFoundError{Index: len(parts) - 1} + return tree.TreeEntry{}, &PathNotFoundError{Index: len(parts) - 1} } diff --git a/object/resolve/peel_to_blob.go b/object/resolve/peel_to_blob.go index 424e309f..c8aec1ad 100644 --- a/object/resolve/peel_to_blob.go +++ b/object/resolve/peel_to_blob.go @@ -3,13 +3,14 @@ package resolve import ( "fmt" - "codeberg.org/lindenii/furgit/object" + "codeberg.org/lindenii/furgit/object/blob" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/stored" + "codeberg.org/lindenii/furgit/object/tag" ) // PeelToBlob peels tags until it reaches a blob. -func (r *Resolver) PeelToBlob(id objectid.ObjectID) (*stored.Stored[*object.Blob], error) { +func (r *Resolver) PeelToBlob(id objectid.ObjectID) (*stored.Stored[*blob.Blob], error) { for { obj, err := r.ExactObject(id) if err != nil { @@ -17,9 +18,9 @@ func (r *Resolver) PeelToBlob(id objectid.ObjectID) (*stored.Stored[*object.Blob } switch parsed := obj.Object().(type) { - case *object.Blob: + case *blob.Blob: return stored.New(id, parsed), nil - case *object.Tag: + case *tag.Tag: id = parsed.Target default: return nil, fmt.Errorf("object/resolve: expected blob-ish object %s, got %v", id, parsed.ObjectType()) diff --git a/object/resolve/peel_to_commit.go b/object/resolve/peel_to_commit.go index 355a3055..0272dd83 100644 --- a/object/resolve/peel_to_commit.go +++ b/object/resolve/peel_to_commit.go @@ -3,13 +3,14 @@ package resolve import ( "fmt" - "codeberg.org/lindenii/furgit/object" + "codeberg.org/lindenii/furgit/object/commit" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/stored" + "codeberg.org/lindenii/furgit/object/tag" ) // PeelToCommit peels tags until it reaches a commit. -func (r *Resolver) PeelToCommit(id objectid.ObjectID) (*stored.Stored[*object.Commit], error) { +func (r *Resolver) PeelToCommit(id objectid.ObjectID) (*stored.Stored[*commit.Commit], error) { for { obj, err := r.ExactObject(id) if err != nil { @@ -17,9 +18,9 @@ func (r *Resolver) PeelToCommit(id objectid.ObjectID) (*stored.Stored[*object.Co } switch parsed := obj.Object().(type) { - case *object.Commit: + case *commit.Commit: return stored.New(id, parsed), nil - case *object.Tag: + case *tag.Tag: id = parsed.Target default: return nil, fmt.Errorf("object/resolve: expected commit-ish object %s, got %v", id, parsed.ObjectType()) diff --git a/object/resolve/peel_to_tag.go b/object/resolve/peel_to_tag.go index 6f61d7e1..e131f4c1 100644 --- a/object/resolve/peel_to_tag.go +++ b/object/resolve/peel_to_tag.go @@ -1,12 +1,12 @@ package resolve import ( - "codeberg.org/lindenii/furgit/object" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/stored" + "codeberg.org/lindenii/furgit/object/tag" ) // PeelToTag returns the tag at id without further peeling. -func (r *Resolver) PeelToTag(id objectid.ObjectID) (*stored.Stored[*object.Tag], error) { +func (r *Resolver) PeelToTag(id objectid.ObjectID) (*stored.Stored[*tag.Tag], error) { return r.ExactTag(id) } diff --git a/object/resolve/peel_to_tree.go b/object/resolve/peel_to_tree.go index 3b12bdd8..2f2da4d7 100644 --- a/object/resolve/peel_to_tree.go +++ b/object/resolve/peel_to_tree.go @@ -3,14 +3,16 @@ package resolve import ( "fmt" - "codeberg.org/lindenii/furgit/object" + "codeberg.org/lindenii/furgit/object/commit" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/stored" + "codeberg.org/lindenii/furgit/object/tag" + "codeberg.org/lindenii/furgit/object/tree" ) // PeelToTree peels tags until it reaches a tree or commit. If it reaches a // commit, it returns the commit's root tree. -func (r *Resolver) PeelToTree(id objectid.ObjectID) (*stored.Stored[*object.Tree], error) { +func (r *Resolver) PeelToTree(id objectid.ObjectID) (*stored.Stored[*tree.Tree], error) { for { obj, err := r.ExactObject(id) if err != nil { @@ -18,11 +20,11 @@ func (r *Resolver) PeelToTree(id objectid.ObjectID) (*stored.Stored[*object.Tree } switch parsed := obj.Object().(type) { - case *object.Tree: + case *tree.Tree: return stored.New(id, parsed), nil - case *object.Commit: + case *commit.Commit: return r.ExactTree(parsed.Tree) - case *object.Tag: + case *tag.Tag: id = parsed.Target default: return nil, fmt.Errorf("object/resolve: expected tree-ish object %s, got %v", id, parsed.ObjectType()) diff --git a/object/resolve/resolver.go b/object/resolve/resolver.go index 3e76e96a..f5e4e8c3 100644 --- a/object/resolve/resolver.go +++ b/object/resolve/resolver.go @@ -1,6 +1,6 @@ package resolve -import "codeberg.org/lindenii/furgit/object/storer" +import objectstorer "codeberg.org/lindenii/furgit/object/storer" // Resolver resolves parsed and streamed objects from an object store. // diff --git a/object/resolve/treefs.go b/object/resolve/treefs.go index de5d588a..a080d56d 100644 --- a/object/resolve/treefs.go +++ b/object/resolve/treefs.go @@ -3,8 +3,8 @@ package resolve import ( "io/fs" - "codeberg.org/lindenii/furgit/object" objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/tree" ) // TreeFS exposes one Git tree as an fs.FS. @@ -18,7 +18,7 @@ import ( type TreeFS struct { resolver *Resolver rootTree objectid.ObjectID - rootEntry *object.TreeEntry + rootEntry *tree.TreeEntry } var ( diff --git a/object/resolve/treefs_entry.go b/object/resolve/treefs_entry.go index b37ac0a0..6d23e282 100644 --- a/object/resolve/treefs_entry.go +++ b/object/resolve/treefs_entry.go @@ -5,8 +5,8 @@ import ( "fmt" "io/fs" - "codeberg.org/lindenii/furgit/object" objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/tree" ) func (treeFS *TreeFS) resolvePath(op treeFSOp, name string) (treeEntryValue, error) { @@ -17,7 +17,7 @@ func (treeFS *TreeFS) resolvePath(op treeFSOp, name string) (treeEntryValue, err if name == "." { return treeEntryValue{ name: ".", - mode: object.FileModeDir, + mode: tree.FileModeDir, treeID: treeFS.rootTree, treeEntry: treeFS.rootEntry, }, nil @@ -58,14 +58,14 @@ func (treeFS *TreeFS) pathResolveError(op treeFSOp, name string, err error) erro type treeEntryValue struct { name string - mode object.FileMode + mode tree.FileMode objectID objectid.ObjectID treeID objectid.ObjectID - treeEntry *object.TreeEntry + treeEntry *tree.TreeEntry } func (entry treeEntryValue) isDir() bool { - return entry.mode == object.FileModeDir + return entry.mode == tree.FileModeDir } func (entry treeEntryValue) blobSize(resolve *Resolver) (int64, error) { @@ -82,7 +82,7 @@ func (entry treeEntryValue) subtreeID() (objectid.ObjectID, error) { return entry.treeID, nil } - if entry.mode != object.FileModeDir { + if entry.mode != tree.FileModeDir { return objectid.ObjectID{}, fmt.Errorf("object/resolve: path %q is not a tree", entry.name) } diff --git a/object/resolve/treefs_info.go b/object/resolve/treefs_info.go index f554973d..f8eb1e9e 100644 --- a/object/resolve/treefs_info.go +++ b/object/resolve/treefs_info.go @@ -4,7 +4,7 @@ import ( "io/fs" "time" - "codeberg.org/lindenii/furgit/object" + "codeberg.org/lindenii/furgit/object/tree" ) type treeFSInfo struct { @@ -31,17 +31,17 @@ func (info *treeFSInfo) Info() (fs.FileInfo, error) { return info, nil } -func treeFSEntryMode(mode object.FileMode) fs.FileMode { +func treeFSEntryMode(mode tree.FileMode) fs.FileMode { switch mode { - case object.FileModeDir: + case tree.FileModeDir: return fs.ModeDir | 0o555 - case object.FileModeRegular: + case tree.FileModeRegular: return 0o444 - case object.FileModeExecutable: + case tree.FileModeExecutable: return 0o555 - case object.FileModeSymlink: + case tree.FileModeSymlink: return fs.ModeSymlink | 0o444 - case object.FileModeGitlink: + case tree.FileModeGitlink: return fs.ModeIrregular default: return fs.ModeIrregular @@ -51,7 +51,7 @@ func treeFSEntryMode(mode object.FileMode) fs.FileMode { func (treeFS *TreeFS) statEntry(entry treeEntryValue) (*treeFSInfo, error) { size := int64(0) - if entry.mode == object.FileModeRegular || entry.mode == object.FileModeExecutable || entry.mode == object.FileModeSymlink { + if entry.mode == tree.FileModeRegular || entry.mode == tree.FileModeExecutable || entry.mode == tree.FileModeSymlink { var err error size, err = entry.blobSize(treeFS.resolver) diff --git a/object/resolve/treefs_open.go b/object/resolve/treefs_open.go index c938505b..8e2b3588 100644 --- a/object/resolve/treefs_open.go +++ b/object/resolve/treefs_open.go @@ -5,7 +5,7 @@ import ( "io" "io/fs" - "codeberg.org/lindenii/furgit/object" + "codeberg.org/lindenii/furgit/object/tree" ) // Open opens name for reading. @@ -57,7 +57,7 @@ func (treeFS *TreeFS) Open(name string) (fs.File, error) { }, nil } - if entry.mode == object.FileModeGitlink { + if entry.mode == tree.FileModeGitlink { return nil, treeFSPathError(treeFSOpOpen, name, fmt.Errorf("object/resolve: gitlink entries are not readable as files")) } diff --git a/object/resolve/treefs_readfile.go b/object/resolve/treefs_readfile.go index e2bc1698..e1d514a3 100644 --- a/object/resolve/treefs_readfile.go +++ b/object/resolve/treefs_readfile.go @@ -4,7 +4,7 @@ import ( "fmt" "io" - "codeberg.org/lindenii/furgit/object" + "codeberg.org/lindenii/furgit/object/tree" ) // ReadFile reads the blob contents at name. @@ -20,7 +20,7 @@ func (treeFS *TreeFS) ReadFile(name string) ([]byte, error) { return nil, treeFSPathError(treeFSOpReadFile, name, fmt.Errorf("is a directory")) } - if entry.mode == object.FileModeGitlink { + if entry.mode == tree.FileModeGitlink { return nil, treeFSPathError(treeFSOpReadFile, name, fmt.Errorf("object/resolve: gitlink entries are not readable as files")) } diff --git a/object/resolve/treefs_stat.go b/object/resolve/treefs_stat.go index 044ba049..396dfbae 100644 --- a/object/resolve/treefs_stat.go +++ b/object/resolve/treefs_stat.go @@ -6,7 +6,7 @@ import "io/fs" // // TreeFS metadata reflects Git tree entry mode and blob size where applicable. // It does not represent filesystem stat metadata: ModTime is zero, ownership is -// unavailable, and Sys returns the underlying object.TreeEntry when one exists. +// unavailable, and Sys returns the underlying tree.TreeEntry when one exists. func (treeFS *TreeFS) Stat(name string) (fs.FileInfo, error) { entry, err := treeFS.resolvePath(treeFSOpStat, name) if err != nil { diff --git a/object/resolve/treefs_test.go b/object/resolve/treefs_test.go index a22e5019..0c436c0b 100644 --- a/object/resolve/treefs_test.go +++ b/object/resolve/treefs_test.go @@ -6,9 +6,9 @@ import ( "testing" "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/resolve" + "codeberg.org/lindenii/furgit/object/tree" "codeberg.org/lindenii/furgit/repository" ) @@ -71,13 +71,13 @@ func TestTreeFS(t *testing.T) { t.Fatalf("Stat(plain.txt): %v", err) } - entry, ok := info.Sys().(object.TreeEntry) + entry, ok := info.Sys().(tree.TreeEntry) if !ok { - t.Fatalf("Stat(plain.txt).Sys() type = %T, want object.TreeEntry", info.Sys()) + t.Fatalf("Stat(plain.txt).Sys() type = %T, want tree.TreeEntry", info.Sys()) } - if entry.Mode != object.FileModeRegular { - t.Fatalf("Stat(plain.txt).Sys().Mode = %o, want %o", entry.Mode, object.FileModeRegular) + if entry.Mode != tree.FileModeRegular { + t.Fatalf("Stat(plain.txt).Sys().Mode = %o, want %o", entry.Mode, tree.FileModeRegular) } subFS, err := treeFS.Sub("dir") diff --git a/object/signature/parse.go b/object/signature/parse.go new file mode 100644 index 00000000..a6880eee --- /dev/null +++ b/object/signature/parse.go @@ -0,0 +1,97 @@ +package signature + +import ( + "bytes" + "errors" + "fmt" + "strconv" + + "codeberg.org/lindenii/furgit/internal/intconv" +) + +// Parse parses a canonical Git signature line: +// "Name 123456789 +0000". +func Parse(line []byte) (*Signature, error) { + lt := bytes.IndexByte(line, '<') + if lt < 0 { + return nil, errors.New("object: signature: missing opening <") + } + + gtRel := bytes.IndexByte(line[lt+1:], '>') + if gtRel < 0 { + return nil, errors.New("object: signature: 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: signature: missing timestamp separator") + } + + rest = rest[1:] + + before, after, ok := bytes.Cut(rest, []byte{' '}) + if !ok { + return nil, errors.New("object: signature: missing timezone separator") + } + + when, err := strconv.ParseInt(string(before), 10, 64) + if err != nil { + return nil, fmt.Errorf("object: signature: invalid timestamp: %w", err) + } + + tz := after + if len(tz) < 5 { + return nil, errors.New("object: signature: invalid timezone encoding") + } + + sign := 1 + + switch tz[0] { + case '-': + sign = -1 + case '+': + default: + return nil, errors.New("object: signature: invalid timezone sign") + } + + hh, err := strconv.Atoi(string(tz[1:3])) + if err != nil { + return nil, fmt.Errorf("object: signature: invalid timezone hours: %w", err) + } + + mm, err := strconv.Atoi(string(tz[3:5])) + if err != nil { + return nil, fmt.Errorf("object: signature: invalid timezone minutes: %w", err) + } + + if hh < 0 || hh > 23 { + return nil, errors.New("object: signature: invalid timezone hours range") + } + + if mm < 0 || mm > 59 { + return nil, errors.New("object: signature: invalid timezone minutes range") + } + + total := int64(hh)*60 + int64(mm) + + offset, err := intconv.Int64ToInt32(total) + if err != nil { + return nil, errors.New("object: signature: timezone overflow") + } + + if sign < 0 { + offset = -offset + } + + return &Signature{ + Name: nameBytes, + Email: emailBytes, + WhenUnix: when, + OffsetMinutes: offset, + }, nil +} diff --git a/object/signature/serialize.go b/object/signature/serialize.go new file mode 100644 index 00000000..3f60d20d --- /dev/null +++ b/object/signature/serialize.go @@ -0,0 +1,33 @@ +package signature + +import ( + "fmt" + "strconv" + "strings" +) + +// Serialize renders the signature in canonical Git format. +func (signature Signature) Serialize() ([]byte, error) { + var b strings.Builder + b.Grow(len(signature.Name) + len(signature.Email) + 32) + b.Write(signature.Name) + b.WriteString(" <") + b.Write(signature.Email) + b.WriteString("> ") + b.WriteString(strconv.FormatInt(signature.WhenUnix, 10)) + b.WriteByte(' ') + + offset := signature.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 +} diff --git a/object/signature/signature.go b/object/signature/signature.go new file mode 100644 index 00000000..22e516f9 --- /dev/null +++ b/object/signature/signature.go @@ -0,0 +1,10 @@ +// Package signature provides routines and representations that implement author/committer/tagger signatures. +package signature + +// Signature represents a Git signature (author/committer/tagger). +type Signature struct { + Name []byte + Email []byte + WhenUnix int64 + OffsetMinutes int32 +} diff --git a/object/signature/when.go b/object/signature/when.go new file mode 100644 index 00000000..0a252f68 --- /dev/null +++ b/object/signature/when.go @@ -0,0 +1,10 @@ +package signature + +import "time" + +// When returns a time.Time with the signature's timezone offset. +func (signature Signature) When() time.Time { + loc := time.FixedZone("git", int(signature.OffsetMinutes)*60) + + return time.Unix(signature.WhenUnix, 0).In(loc) +} diff --git a/object/stored/stored.go b/object/stored/stored.go index f48aaa77..4429a373 100644 --- a/object/stored/stored.go +++ b/object/stored/stored.go @@ -1,7 +1,7 @@ // Package stored wraps parsed objects with their storage object IDs. // // Stored values are typically instantiated with pointer object types such as -// *object.Blob, *object.Tree, *object.Commit, or *object.Tag, because those +// *blob.Blob, *tree.Tree, *commit.Commit, or *tag.Tag, because those // pointer types satisfy object.Object. package stored diff --git a/object/storer/chain/bytes.go b/object/storer/chain/bytes.go index c3ec1eb8..d41f3b92 100644 --- a/object/storer/chain/bytes.go +++ b/object/storer/chain/bytes.go @@ -5,7 +5,7 @@ import ( "fmt" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/chain/chain.go b/object/storer/chain/chain.go index 8502b590..44909cff 100644 --- a/object/storer/chain/chain.go +++ b/object/storer/chain/chain.go @@ -2,9 +2,7 @@ // backends. package chain -import ( - "codeberg.org/lindenii/furgit/object/storer" -) +import objectstorer "codeberg.org/lindenii/furgit/object/storer" // Chain queries multiple object databases in order. // diff --git a/object/storer/chain/header.go b/object/storer/chain/header.go index e7791e9e..4feaf8e4 100644 --- a/object/storer/chain/header.go +++ b/object/storer/chain/header.go @@ -5,7 +5,7 @@ import ( "fmt" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/chain/new.go b/object/storer/chain/new.go index f7a4f141..0368bfb3 100644 --- a/object/storer/chain/new.go +++ b/object/storer/chain/new.go @@ -1,6 +1,6 @@ package chain -import "codeberg.org/lindenii/furgit/object/storer" +import objectstorer "codeberg.org/lindenii/furgit/object/storer" // New creates an ordered object database chain. // diff --git a/object/storer/chain/reader.go b/object/storer/chain/reader.go index 3ac8cce7..e3c50013 100644 --- a/object/storer/chain/reader.go +++ b/object/storer/chain/reader.go @@ -6,7 +6,7 @@ import ( "io" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/chain/size.go b/object/storer/chain/size.go index 6ad7d12c..c82b248d 100644 --- a/object/storer/chain/size.go +++ b/object/storer/chain/size.go @@ -5,7 +5,7 @@ import ( "fmt" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" ) // ReadSize reads object content length from the first backend that has it. diff --git a/object/storer/loose/paths.go b/object/storer/loose/paths.go index 73cb0cf3..58ef6b8e 100644 --- a/object/storer/loose/paths.go +++ b/object/storer/loose/paths.go @@ -8,7 +8,7 @@ import ( "path/filepath" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" ) // objectPath returns the loose object path for id relative to the objects root. diff --git a/object/storer/loose/read_test.go b/object/storer/loose/read_test.go index ece3c9db..d44ecea8 100644 --- a/object/storer/loose/read_test.go +++ b/object/storer/loose/read_test.go @@ -9,7 +9,7 @@ import ( "codeberg.org/lindenii/furgit/internal/testgit" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" "codeberg.org/lindenii/furgit/object/storer/loose" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/memory/read_bytes.go b/object/storer/memory/read_bytes.go index 72eaba11..e8b437ea 100644 --- a/object/storer/memory/read_bytes.go +++ b/object/storer/memory/read_bytes.go @@ -3,7 +3,7 @@ package memory import ( objectheader "codeberg.org/lindenii/furgit/object/header" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/memory/read_header.go b/object/storer/memory/read_header.go index 56979d3c..73cc4561 100644 --- a/object/storer/memory/read_header.go +++ b/object/storer/memory/read_header.go @@ -2,7 +2,7 @@ package memory import ( objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/mix/bytes.go b/object/storer/mix/bytes.go index d2a7dc0e..a281c332 100644 --- a/object/storer/mix/bytes.go +++ b/object/storer/mix/bytes.go @@ -5,7 +5,7 @@ import ( "fmt" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/mix/header.go b/object/storer/mix/header.go index 6a5abf26..8bab48c2 100644 --- a/object/storer/mix/header.go +++ b/object/storer/mix/header.go @@ -5,7 +5,7 @@ import ( "fmt" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/mix/mix.go b/object/storer/mix/mix.go index 9edda31e..6154314f 100644 --- a/object/storer/mix/mix.go +++ b/object/storer/mix/mix.go @@ -5,7 +5,7 @@ package mix import ( "sync" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" ) // Mix queries multiple object databases with an MRU backend preference. diff --git a/object/storer/mix/mru.go b/object/storer/mix/mru.go index 172a641a..644fe818 100644 --- a/object/storer/mix/mru.go +++ b/object/storer/mix/mru.go @@ -1,6 +1,6 @@ package mix -import "codeberg.org/lindenii/furgit/object/storer" +import objectstorer "codeberg.org/lindenii/furgit/object/storer" type backendNode struct { backend objectstorer.Store diff --git a/object/storer/mix/new.go b/object/storer/mix/new.go index f92e2724..c59d3b9f 100644 --- a/object/storer/mix/new.go +++ b/object/storer/mix/new.go @@ -1,6 +1,6 @@ package mix -import "codeberg.org/lindenii/furgit/object/storer" +import objectstorer "codeberg.org/lindenii/furgit/object/storer" // New creates a Mix from backends. // diff --git a/object/storer/mix/reader.go b/object/storer/mix/reader.go index 66fce069..7ddbaa4e 100644 --- a/object/storer/mix/reader.go +++ b/object/storer/mix/reader.go @@ -6,7 +6,7 @@ import ( "io" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" objecttype "codeberg.org/lindenii/furgit/object/type" ) diff --git a/object/storer/mix/refresh.go b/object/storer/mix/refresh.go index 916d9e8f..2e861a61 100644 --- a/object/storer/mix/refresh.go +++ b/object/storer/mix/refresh.go @@ -3,7 +3,7 @@ package mix import ( "errors" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" ) // Refresh forwards refresh calls to refresh-capable backends. diff --git a/object/storer/mix/size.go b/object/storer/mix/size.go index b761177d..da8e02b7 100644 --- a/object/storer/mix/size.go +++ b/object/storer/mix/size.go @@ -5,7 +5,7 @@ import ( "fmt" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" ) // ReadSize reads object content length from one backend that has it. diff --git a/object/storer/packed/read_test.go b/object/storer/packed/read_test.go index 4686d192..841019fe 100644 --- a/object/storer/packed/read_test.go +++ b/object/storer/packed/read_test.go @@ -11,7 +11,7 @@ import ( "codeberg.org/lindenii/furgit/internal/testgit" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" "codeberg.org/lindenii/furgit/object/storer/packed" ) diff --git a/object/storer/packed/store.go b/object/storer/packed/store.go index a95bedd7..99556d32 100644 --- a/object/storer/packed/store.go +++ b/object/storer/packed/store.go @@ -7,7 +7,7 @@ import ( "sync/atomic" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" ) // Store reads Git objects from pack/index files under an objects/pack root. diff --git a/object/storer/packed/store_lookup.go b/object/storer/packed/store_lookup.go index 3985463b..accb2d25 100644 --- a/object/storer/packed/store_lookup.go +++ b/object/storer/packed/store_lookup.go @@ -4,7 +4,7 @@ import ( "errors" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/object/storer" + objectstorer "codeberg.org/lindenii/furgit/object/storer" ) // lookup resolves one object ID to its pack location. diff --git a/object/tag.go b/object/tag.go deleted file mode 100644 index 50c4b273..00000000 --- a/object/tag.go +++ /dev/null @@ -1,22 +0,0 @@ -package object - -import ( - objectid "codeberg.org/lindenii/furgit/object/id" - objecttype "codeberg.org/lindenii/furgit/object/type" -) - -// Tag represents a Git annotated tag object. -type Tag struct { - Target objectid.ObjectID - TargetType objecttype.Type - Name []byte - Tagger *Signature - Message []byte -} - -// ObjectType returns TypeTag. -func (tag *Tag) ObjectType() objecttype.Type { - _ = tag - - return objecttype.TypeTag -} diff --git a/object/tag/parse.go b/object/tag/parse.go new file mode 100644 index 00000000..f24d5965 --- /dev/null +++ b/object/tag/parse.go @@ -0,0 +1,89 @@ +package tag + +import ( + "bytes" + "errors" + "fmt" + + objectid "codeberg.org/lindenii/furgit/object/id" + objectsignature "codeberg.org/lindenii/furgit/object/signature" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +// Parse decodes a tag object body. +func Parse(body []byte, algo objectid.Algorithm) (*Tag, error) { + 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 := objectid.ParseHex(algo, string(value)) + if err != nil { + return nil, fmt.Errorf("object: tag: object: %w", err) + } + + t.Target = id + haveTarget = true + case "type": + ty, ok := objecttype.ParseName(string(value)) + if !ok { + 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 := objectsignature.Parse(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 +} diff --git a/object/tag/parse_test.go b/object/tag/parse_test.go new file mode 100644 index 00000000..293350ed --- /dev/null +++ b/object/tag/parse_test.go @@ -0,0 +1,47 @@ +package tag_test + +import ( + "bytes" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/tag" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +func TestTagParseFromGit(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") + tagID := testRepo.TagAnnotated(t, "v1", commitID, "tag message") + + rawBody := testRepo.CatFile(t, "tag", tagID) + + parsed, err := tag.Parse(rawBody, algo) + if err != nil { + t.Fatalf("ParseTag: %v", err) + } + + if parsed.Target != commitID { + t.Fatalf("tag target mismatch: got %s want %s", parsed.Target, commitID) + } + + if parsed.TargetType != objecttype.TypeCommit { + t.Fatalf("tag target type = %v, want %v", parsed.TargetType, objecttype.TypeCommit) + } + + if !bytes.Equal(parsed.Name, []byte("v1")) { + t.Fatalf("tag name = %q, want %q", parsed.Name, "v1") + } + + if parsed.Tagger == nil { + t.Fatalf("expected tagger") + } + + if !bytes.Contains(parsed.Message, []byte("tag message")) { + t.Fatalf("tag message mismatch: %q", parsed.Message) + } + }) +} diff --git a/object/tag/serialize.go b/object/tag/serialize.go new file mode 100644 index 00000000..5f712950 --- /dev/null +++ b/object/tag/serialize.go @@ -0,0 +1,68 @@ +package tag + +import ( + "bytes" + "errors" + "fmt" + + objectheader "codeberg.org/lindenii/furgit/object/header" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +// SerializeWithoutHeader renders the raw tag body bytes. +func (tag *Tag) SerializeWithoutHeader() ([]byte, error) { + if tag.Target.Size() == 0 { + return nil, errors.New("object: tag: missing target id") + } + + var buf bytes.Buffer + fmt.Fprintf(&buf, "object %s\n", tag.Target.String()) + + tyName, ok := objecttype.Name(tag.TargetType) + if !ok { + 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 +} + +// SerializeWithHeader renders the raw object (header + body). +func (tag *Tag) SerializeWithHeader() ([]byte, error) { + body, err := tag.SerializeWithoutHeader() + if err != nil { + return nil, err + } + + header, ok := objectheader.Encode(objecttype.TypeTag, int64(len(body))) + if !ok { + return nil, errors.New("object: tag: 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/tag/serialize_test.go b/object/tag/serialize_test.go new file mode 100644 index 00000000..a1311c39 --- /dev/null +++ b/object/tag/serialize_test.go @@ -0,0 +1,35 @@ +package tag_test + +import ( + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/tag" +) + +func TestTagSerialize(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") + tagID := testRepo.TagAnnotated(t, "v1", commitID, "tag message") + + rawBody := testRepo.CatFile(t, "tag", tagID) + + parsed, err := tag.Parse(rawBody, algo) + if err != nil { + t.Fatalf("ParseTag: %v", err) + } + + rawObj, err := parsed.SerializeWithHeader() + if err != nil { + t.Fatalf("SerializeWithHeader: %v", err) + } + + gotID := algo.Sum(rawObj) + if gotID != tagID { + t.Fatalf("tag id mismatch: got %s want %s", gotID, tagID) + } + }) +} diff --git a/object/tag/tag.go b/object/tag/tag.go new file mode 100644 index 00000000..4301557e --- /dev/null +++ b/object/tag/tag.go @@ -0,0 +1,17 @@ +// Package tag provides representations, parsers, and serializers for tag objects. +package tag + +import ( + objectid "codeberg.org/lindenii/furgit/object/id" + objectsignature "codeberg.org/lindenii/furgit/object/signature" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +// Tag represents a Git annotated tag object. +type Tag struct { + Target objectid.ObjectID + TargetType objecttype.Type + Name []byte + Tagger *objectsignature.Signature + Message []byte +} diff --git a/object/tag/type.go b/object/tag/type.go new file mode 100644 index 00000000..215103ab --- /dev/null +++ b/object/tag/type.go @@ -0,0 +1,10 @@ +package tag + +import objecttype "codeberg.org/lindenii/furgit/object/type" + +// ObjectType returns TypeTag. +func (tag *Tag) ObjectType() objecttype.Type { + _ = tag + + return objecttype.TypeTag +} diff --git a/object/tag_parse.go b/object/tag_parse.go deleted file mode 100644 index afc9a2e9..00000000 --- a/object/tag_parse.go +++ /dev/null @@ -1,88 +0,0 @@ -package object - -import ( - "bytes" - "errors" - "fmt" - - objectid "codeberg.org/lindenii/furgit/object/id" - objecttype "codeberg.org/lindenii/furgit/object/type" -) - -// ParseTag decodes a tag object body. -func ParseTag(body []byte, algo objectid.Algorithm) (*Tag, error) { - 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 := objectid.ParseHex(algo, string(value)) - if err != nil { - return nil, fmt.Errorf("object: tag: object: %w", err) - } - - t.Target = id - haveTarget = true - case "type": - ty, ok := objecttype.ParseName(string(value)) - if !ok { - 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 := ParseSignature(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 -} diff --git a/object/tag_parse_test.go b/object/tag_parse_test.go deleted file mode 100644 index 07998f1c..00000000 --- a/object/tag_parse_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package object_test - -import ( - "bytes" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" - objectid "codeberg.org/lindenii/furgit/object/id" - objecttype "codeberg.org/lindenii/furgit/object/type" -) - -func TestTagParseFromGit(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") - tagID := testRepo.TagAnnotated(t, "v1", commitID, "tag message") - - rawBody := testRepo.CatFile(t, "tag", tagID) - - tag, err := object.ParseTag(rawBody, algo) - if err != nil { - t.Fatalf("ParseTag: %v", err) - } - - if tag.Target != commitID { - t.Fatalf("tag target mismatch: got %s want %s", tag.Target, commitID) - } - - if tag.TargetType != objecttype.TypeCommit { - t.Fatalf("tag target type = %v, want %v", tag.TargetType, objecttype.TypeCommit) - } - - if !bytes.Equal(tag.Name, []byte("v1")) { - t.Fatalf("tag name = %q, want %q", tag.Name, "v1") - } - - if tag.Tagger == nil { - t.Fatalf("expected tagger") - } - - if !bytes.Contains(tag.Message, []byte("tag message")) { - t.Fatalf("tag message mismatch: %q", tag.Message) - } - }) -} diff --git a/object/tag_serialize.go b/object/tag_serialize.go deleted file mode 100644 index c914e8dd..00000000 --- a/object/tag_serialize.go +++ /dev/null @@ -1,68 +0,0 @@ -package object - -import ( - "bytes" - "errors" - "fmt" - - objectheader "codeberg.org/lindenii/furgit/object/header" - objecttype "codeberg.org/lindenii/furgit/object/type" -) - -// SerializeWithoutHeader renders the raw tag body bytes. -func (tag *Tag) SerializeWithoutHeader() ([]byte, error) { - if tag.Target.Size() == 0 { - return nil, errors.New("object: tag: missing target id") - } - - var buf bytes.Buffer - fmt.Fprintf(&buf, "object %s\n", tag.Target.String()) - - tyName, ok := objecttype.Name(tag.TargetType) - if !ok { - 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 -} - -// SerializeWithHeader renders the raw object (header + body). -func (tag *Tag) SerializeWithHeader() ([]byte, error) { - body, err := tag.SerializeWithoutHeader() - if err != nil { - return nil, err - } - - header, ok := objectheader.Encode(objecttype.TypeTag, int64(len(body))) - if !ok { - return nil, errors.New("object: tag: 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/tag_serialize_test.go b/object/tag_serialize_test.go deleted file mode 100644 index de9f813d..00000000 --- a/object/tag_serialize_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package object_test - -import ( - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" - objectid "codeberg.org/lindenii/furgit/object/id" -) - -func TestTagSerialize(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") - tagID := testRepo.TagAnnotated(t, "v1", commitID, "tag message") - - rawBody := testRepo.CatFile(t, "tag", tagID) - - tag, err := object.ParseTag(rawBody, algo) - if err != nil { - t.Fatalf("ParseTag: %v", err) - } - - rawObj, err := tag.SerializeWithHeader() - if err != nil { - t.Fatalf("SerializeWithHeader: %v", err) - } - - gotID := algo.Sum(rawObj) - if gotID != tagID { - t.Fatalf("tag id mismatch: got %s want %s", gotID, tagID) - } - }) -} diff --git a/object/tree.go b/object/tree.go deleted file mode 100644 index 83dcb508..00000000 --- a/object/tree.go +++ /dev/null @@ -1,163 +0,0 @@ -package object - -import ( - "bytes" - "fmt" - "sort" - - objectid "codeberg.org/lindenii/furgit/object/id" - objecttype "codeberg.org/lindenii/furgit/object/type" -) - -// 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 objectid.ObjectID -} - -// Tree represents a Git tree object. -type Tree struct { - Entries []TreeEntry -} - -// ObjectType returns TypeTree. -func (tree *Tree) ObjectType() objecttype.Type { - _ = tree - - return objecttype.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) -} - -// InsertEntry inserts a tree entry while preserving Git ordering. -func (tree *Tree) InsertEntry(newEntry TreeEntry) error { - 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 len(tree.Entries) == 0 { - return fmt.Errorf("object: tree: entry %q not found", name) - } - - 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 fmt.Errorf("object: tree: entry %q not found", name) -} - -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 -} - -// 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 := min(searchLen, entryLen) - - for i := range n { - 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 -} diff --git a/object/tree/entry.go b/object/tree/entry.go new file mode 100644 index 00000000..cddcde73 --- /dev/null +++ b/object/tree/entry.go @@ -0,0 +1,39 @@ +package tree + +import ( + "bytes" + + objectid "codeberg.org/lindenii/furgit/object/id" +) + +// TreeEntry represents a single entry in a tree. +type TreeEntry struct { + Mode FileMode + Name []byte + ID objectid.ObjectID +} + +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 +} diff --git a/object/tree/helpers_test.go b/object/tree/helpers_test.go new file mode 100644 index 00000000..3da92ce4 --- /dev/null +++ b/object/tree/helpers_test.go @@ -0,0 +1,114 @@ +package tree_test + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + "codeberg.org/lindenii/furgit/object/tree" +) + +func buildGitMktreeInput(entries []tree.TreeEntry) string { + var b strings.Builder + for _, e := range entries { + fmt.Fprintf(&b, "%o %s %s\t%s\n", e.Mode, mktreeTypeFromMode(e.Mode), e.ID.String(), e.Name) + } + + return b.String() +} + +func mktreeTypeFromMode(mode tree.FileMode) string { + switch mode { + case tree.FileModeDir: + return "tree" + case tree.FileModeRegular, tree.FileModeExecutable, tree.FileModeSymlink: + return "blob" + case tree.FileModeGitlink: + return "commit" + default: + return "" + } +} + +func gitLsTreeNames(out []byte) [][]byte { + if len(out) == 0 { + return nil + } + + parts := bytes.Split(out, []byte{0}) + if len(parts) > 0 && len(parts[len(parts)-1]) == 0 { + parts = parts[:len(parts)-1] + } + + names := make([][]byte, 0, len(parts)) + for _, name := range parts { + names = append(names, append([]byte(nil), name...)) + } + + return names +} + +func adversarialRootEntries(t *testing.T, testRepo *testgit.TestRepo) []tree.TreeEntry { + t.Helper() + + blobA := testRepo.HashObject(t, "blob", []byte("blob-A\n")) + blobB := testRepo.HashObject(t, "blob", []byte("blob-B\n")) + blobC := testRepo.HashObject(t, "blob", []byte("blob-C\n")) + + subDirA := testRepo.Mktree(t, + fmt.Sprintf("100644 blob %s\tnested-a.txt\n100755 blob %s\trun-a.sh\n", blobA.String(), blobB.String())) + subDirB := testRepo.Mktree(t, + fmt.Sprintf("100644 blob %s\tnested-b.txt\n100644 blob %s\tz-last\n", blobB.String(), blobC.String())) + subDirC := testRepo.Mktree(t, + fmt.Sprintf("120000 blob %s\tlink-c\n100644 blob %s\tchild\n", blobC.String(), blobA.String())) + subDirD := testRepo.Mktree(t, + fmt.Sprintf("100644 blob %s\tleaf\n", blobA.String())) + + return []tree.TreeEntry{ + {Mode: tree.FileModeRegular, Name: []byte("z"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("A"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("aa"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("a0"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("a-"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("a."), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("a_"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("a~"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("Z"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("0"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("9"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("00"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("这是一些非 ASCII 的字符"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("𲰼是新进入 Unicode 的字符"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("Emoji 👀"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("_"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("-dash"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("dot.file"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte(".hidden"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("CAPS"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("caps"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("mixCase"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("name with space"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("name-with-dash"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("name.with.dot"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("name_with_underscore"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("tilde~name"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("brace{name}"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("plus+name"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("equal=name"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("at@name"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("percent%name"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("caret^name"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("comma,name"), ID: blobA}, + {Mode: tree.FileModeRegular, Name: []byte("semi;name"), ID: blobB}, + {Mode: tree.FileModeRegular, Name: []byte("paren(name)"), ID: blobC}, + {Mode: tree.FileModeRegular, Name: []byte("bracket[name]"), ID: blobA}, + {Mode: tree.FileModeExecutable, Name: []byte("exec.sh"), ID: blobB}, + {Mode: tree.FileModeSymlink, Name: []byte("sym.link"), ID: blobC}, + {Mode: tree.FileModeDir, Name: []byte("dir"), ID: subDirA}, + {Mode: tree.FileModeDir, Name: []byte("dir0"), ID: subDirB}, + {Mode: tree.FileModeDir, Name: []byte("dir.space"), ID: subDirC}, + {Mode: tree.FileModeDir, Name: []byte("x"), ID: subDirD}, + } +} diff --git a/object/tree/insert.go b/object/tree/insert.go new file mode 100644 index 00000000..bca4aa49 --- /dev/null +++ b/object/tree/insert.go @@ -0,0 +1,23 @@ +package tree + +import ( + "fmt" + "sort" +) + +// InsertEntry inserts a tree entry while preserving Git ordering. +func (tree *Tree) InsertEntry(newEntry TreeEntry) error { + 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 +} diff --git a/object/tree/lookup.go b/object/tree/lookup.go new file mode 100644 index 00000000..957b31c4 --- /dev/null +++ b/object/tree/lookup.go @@ -0,0 +1,14 @@ +package tree + +// 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) +} diff --git a/object/tree/mode.go b/object/tree/mode.go new file mode 100644 index 00000000..b1cbc6bc --- /dev/null +++ b/object/tree/mode.go @@ -0,0 +1,12 @@ +package tree + +// 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 +) diff --git a/object/tree/name.go b/object/tree/name.go new file mode 100644 index 00000000..02af3292 --- /dev/null +++ b/object/tree/name.go @@ -0,0 +1,51 @@ +package tree + +// 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 := min(searchLen, entryLen) + + for i := range n { + 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 +} diff --git a/object/tree/parse.go b/object/tree/parse.go new file mode 100644 index 00000000..10bef968 --- /dev/null +++ b/object/tree/parse.go @@ -0,0 +1,58 @@ +package tree + +import ( + "bytes" + "fmt" + "strconv" + + objectid "codeberg.org/lindenii/furgit/object/id" +) + +// Parse decodes a tree object body. +func Parse(body []byte, algo objectid.Algorithm) (*Tree, error) { + 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 := objectid.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 +} diff --git a/object/tree/parse_test.go b/object/tree/parse_test.go new file mode 100644 index 00000000..6f00220e --- /dev/null +++ b/object/tree/parse_test.go @@ -0,0 +1,76 @@ +package tree_test + +import ( + "bytes" + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/tree" +) + +func TestTreeParseFromGit(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}) + entries := adversarialRootEntries(t, testRepo) + + inserted := &tree.Tree{} + for _, entry := range entries { + err := inserted.InsertEntry(entry) + if err != nil { + t.Fatalf("InsertEntry(%q): %v", entry.Name, err) + } + } + + treeID := testRepo.Mktree(t, buildGitMktreeInput(inserted.Entries)) + + rawBody := testRepo.CatFile(t, "tree", treeID) + + parsed, err := tree.Parse(rawBody, algo) + if err != nil { + t.Fatalf("ParseTree: %v", err) + } + + if len(parsed.Entries) != len(inserted.Entries) { + t.Fatalf("entry count = %d, want %d", len(parsed.Entries), len(inserted.Entries)) + } + + for i := range inserted.Entries { + got := parsed.Entries[i] + + want := inserted.Entries[i] + if got.Mode != want.Mode || got.ID != want.ID || !bytes.Equal(got.Name, want.Name) { + t.Fatalf("entry[%d] mismatch: got (%o,%q,%s) want (%o,%q,%s)", + i, got.Mode, got.Name, got.ID, want.Mode, want.Name, want.ID) + } + } + + lsNames := gitLsTreeNames(testRepo.RunBytes(t, "ls-tree", "--name-only", "-z", treeID.String())) + if len(lsNames) != len(parsed.Entries) { + t.Fatalf("ls-tree names = %d, want %d", len(lsNames), len(parsed.Entries)) + } + + for i := range lsNames { + if !bytes.Equal(lsNames[i], parsed.Entries[i].Name) { + t.Fatalf("ordering mismatch at %d: git=%q parsed=%q", i, lsNames[i], parsed.Entries[i].Name) + } + } + + for _, want := range inserted.Entries { + got := parsed.Entry(want.Name) + + if got == nil { + t.Fatalf("Entry(%q) returned nil", want.Name) + } + + if got.Mode != want.Mode || got.ID != want.ID { + t.Fatalf("Entry(%q) mismatch", want.Name) + } + } + + if parsed.Entry([]byte("does-not-exist")) != nil { + t.Fatalf("Entry on missing name should be nil") + } + }) +} diff --git a/object/tree/remove.go b/object/tree/remove.go new file mode 100644 index 00000000..9eb42028 --- /dev/null +++ b/object/tree/remove.go @@ -0,0 +1,24 @@ +package tree + +import ( + "bytes" + "fmt" +) + +// RemoveEntry removes a tree entry by name. +func (tree *Tree) RemoveEntry(name []byte) error { + if len(tree.Entries) == 0 { + return fmt.Errorf("object: tree: entry %q not found", name) + } + + 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 fmt.Errorf("object: tree: entry %q not found", name) +} diff --git a/object/tree/serialize.go b/object/tree/serialize.go new file mode 100644 index 00000000..be31297b --- /dev/null +++ b/object/tree/serialize.go @@ -0,0 +1,55 @@ +package tree + +import ( + "errors" + "strconv" + + objectheader "codeberg.org/lindenii/furgit/object/header" + objecttype "codeberg.org/lindenii/furgit/object/type" +) + +// SerializeWithoutHeader renders the raw tree body bytes. +func (tree *Tree) SerializeWithoutHeader() ([]byte, error) { + 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, nil +} + +// SerializeWithHeader renders the raw object (header + body). +func (tree *Tree) SerializeWithHeader() ([]byte, error) { + body, err := tree.SerializeWithoutHeader() + if err != nil { + return nil, err + } + + header, ok := objectheader.Encode(objecttype.TypeTree, int64(len(body))) + if !ok { + return nil, errors.New("object: tree: 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/tree/serialize_test.go b/object/tree/serialize_test.go new file mode 100644 index 00000000..9c9a2f1c --- /dev/null +++ b/object/tree/serialize_test.go @@ -0,0 +1,73 @@ +package tree_test + +import ( + "testing" + + "codeberg.org/lindenii/furgit/internal/testgit" + objectid "codeberg.org/lindenii/furgit/object/id" + "codeberg.org/lindenii/furgit/object/tree" +) + +func TestTreeSerialize(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}) + entries := adversarialRootEntries(t, testRepo) + obj := &tree.Tree{} + + for i := len(entries) - 1; i >= 0; i-- { + err := obj.InsertEntry(entries[i]) + if err != nil { + t.Fatalf("InsertEntry(%q): %v", entries[i].Name, err) + } + } + + if len(obj.Entries) < 32 { + t.Fatalf("expected at least 32 entries, got %d", len(obj.Entries)) + } + + dup := obj.Entries[0] + + err := obj.InsertEntry(dup) + if err == nil { + t.Fatalf("duplicate InsertEntry should fail") + } + + removed := obj.Entries[len(obj.Entries)/2] + + err = obj.RemoveEntry(removed.Name) + if err != nil { + t.Fatalf("RemoveEntry(%q): %v", removed.Name, err) + } + + if obj.Entry(removed.Name) != nil { + t.Fatalf("Entry(%q) should be nil after remove", removed.Name) + } + + err = obj.RemoveEntry([]byte("no-such-entry")) + if err == nil { + t.Fatalf("RemoveEntry missing entry should fail") + } + + err = obj.InsertEntry(removed) + if err != nil { + t.Fatalf("re-InsertEntry(%q): %v", removed.Name, err) + } + + if obj.Entry(removed.Name) == nil { + t.Fatalf("Entry(%q) should exist after reinsert", removed.Name) + } + + wantTreeID := testRepo.Mktree(t, buildGitMktreeInput(obj.Entries)) + + rawObj, err := obj.SerializeWithHeader() + if err != nil { + t.Fatalf("SerializeWithHeader: %v", err) + } + + gotTreeID := algo.Sum(rawObj) + if gotTreeID != wantTreeID { + t.Fatalf("tree id mismatch: got %s want %s", gotTreeID, wantTreeID) + } + }) +} diff --git a/object/tree/tree.go b/object/tree/tree.go new file mode 100644 index 00000000..3ea6f1ee --- /dev/null +++ b/object/tree/tree.go @@ -0,0 +1,7 @@ +// Package tree provides representations, parsers, and serializers for tree objects. +package tree + +// Tree represents a Git tree object. +type Tree struct { + Entries []TreeEntry +} diff --git a/object/tree/type.go b/object/tree/type.go new file mode 100644 index 00000000..416544af --- /dev/null +++ b/object/tree/type.go @@ -0,0 +1,10 @@ +package tree + +import objecttype "codeberg.org/lindenii/furgit/object/type" + +// ObjectType returns TypeTree. +func (tree *Tree) ObjectType() objecttype.Type { + _ = tree + + return objecttype.TypeTree +} diff --git a/object/tree_helpers_test.go b/object/tree_helpers_test.go deleted file mode 100644 index 2577e0e1..00000000 --- a/object/tree_helpers_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package object_test - -import ( - "bytes" - "fmt" - "strings" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" -) - -func buildGitMktreeInput(entries []object.TreeEntry) string { - var b strings.Builder - for _, e := range entries { - fmt.Fprintf(&b, "%o %s %s\t%s\n", e.Mode, mktreeTypeFromMode(e.Mode), e.ID.String(), e.Name) - } - - return b.String() -} - -func mktreeTypeFromMode(mode object.FileMode) string { - switch mode { - case object.FileModeDir: - return "tree" - case object.FileModeRegular, object.FileModeExecutable, object.FileModeSymlink: - return "blob" - case object.FileModeGitlink: - return "commit" - default: - return "" - } -} - -func gitLsTreeNames(out []byte) [][]byte { - if len(out) == 0 { - return nil - } - - parts := bytes.Split(out, []byte{0}) - if len(parts) > 0 && len(parts[len(parts)-1]) == 0 { - parts = parts[:len(parts)-1] - } - - names := make([][]byte, 0, len(parts)) - for _, name := range parts { - names = append(names, append([]byte(nil), name...)) - } - - return names -} - -func adversarialRootEntries(t *testing.T, testRepo *testgit.TestRepo) []object.TreeEntry { - t.Helper() - - blobA := testRepo.HashObject(t, "blob", []byte("blob-A\n")) - blobB := testRepo.HashObject(t, "blob", []byte("blob-B\n")) - blobC := testRepo.HashObject(t, "blob", []byte("blob-C\n")) - - subDirA := testRepo.Mktree(t, - fmt.Sprintf("100644 blob %s\tnested-a.txt\n100755 blob %s\trun-a.sh\n", blobA.String(), blobB.String())) - subDirB := testRepo.Mktree(t, - fmt.Sprintf("100644 blob %s\tnested-b.txt\n100644 blob %s\tz-last\n", blobB.String(), blobC.String())) - subDirC := testRepo.Mktree(t, - fmt.Sprintf("120000 blob %s\tlink-c\n100644 blob %s\tchild\n", blobC.String(), blobA.String())) - subDirD := testRepo.Mktree(t, - fmt.Sprintf("100644 blob %s\tleaf\n", blobA.String())) - - return []object.TreeEntry{ - {Mode: object.FileModeRegular, Name: []byte("z"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("A"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("aa"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("a0"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("a-"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("a."), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("a_"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("a~"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("Z"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("0"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("9"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("00"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("这是一些非 ASCII 的字符"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("𲰼是新进入 Unicode 的字符"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("Emoji 👀"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("_"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("-dash"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("dot.file"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte(".hidden"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("CAPS"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("caps"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("mixCase"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("name with space"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("name-with-dash"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("name.with.dot"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("name_with_underscore"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("tilde~name"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("brace{name}"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("plus+name"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("equal=name"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("at@name"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("percent%name"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("caret^name"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("comma,name"), ID: blobA}, - {Mode: object.FileModeRegular, Name: []byte("semi;name"), ID: blobB}, - {Mode: object.FileModeRegular, Name: []byte("paren(name)"), ID: blobC}, - {Mode: object.FileModeRegular, Name: []byte("bracket[name]"), ID: blobA}, - {Mode: object.FileModeExecutable, Name: []byte("exec.sh"), ID: blobB}, - {Mode: object.FileModeSymlink, Name: []byte("sym.link"), ID: blobC}, - {Mode: object.FileModeDir, Name: []byte("dir"), ID: subDirA}, - {Mode: object.FileModeDir, Name: []byte("dir0"), ID: subDirB}, - {Mode: object.FileModeDir, Name: []byte("dir.space"), ID: subDirC}, - {Mode: object.FileModeDir, Name: []byte("x"), ID: subDirD}, - } -} diff --git a/object/tree_parse.go b/object/tree_parse.go deleted file mode 100644 index 944dc538..00000000 --- a/object/tree_parse.go +++ /dev/null @@ -1,58 +0,0 @@ -package object - -import ( - "bytes" - "fmt" - "strconv" - - objectid "codeberg.org/lindenii/furgit/object/id" -) - -// ParseTree decodes a tree object body. -func ParseTree(body []byte, algo objectid.Algorithm) (*Tree, error) { - 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 := objectid.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 -} diff --git a/object/tree_parse_test.go b/object/tree_parse_test.go deleted file mode 100644 index 2e78243c..00000000 --- a/object/tree_parse_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package object_test - -import ( - "bytes" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" - objectid "codeberg.org/lindenii/furgit/object/id" -) - -func TestTreeParseFromGit(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}) - entries := adversarialRootEntries(t, testRepo) - - inserted := &object.Tree{} - for _, entry := range entries { - err := inserted.InsertEntry(entry) - if err != nil { - t.Fatalf("InsertEntry(%q): %v", entry.Name, err) - } - } - - treeID := testRepo.Mktree(t, buildGitMktreeInput(inserted.Entries)) - - rawBody := testRepo.CatFile(t, "tree", treeID) - - tree, err := object.ParseTree(rawBody, algo) - if err != nil { - t.Fatalf("ParseTree: %v", err) - } - - if len(tree.Entries) != len(inserted.Entries) { - t.Fatalf("entry count = %d, want %d", len(tree.Entries), len(inserted.Entries)) - } - - for i := range inserted.Entries { - got := tree.Entries[i] - - want := inserted.Entries[i] - if got.Mode != want.Mode || got.ID != want.ID || !bytes.Equal(got.Name, want.Name) { - t.Fatalf("entry[%d] mismatch: got (%o,%q,%s) want (%o,%q,%s)", - i, got.Mode, got.Name, got.ID, want.Mode, want.Name, want.ID) - } - } - - lsNames := gitLsTreeNames(testRepo.RunBytes(t, "ls-tree", "--name-only", "-z", treeID.String())) - if len(lsNames) != len(tree.Entries) { - t.Fatalf("ls-tree names = %d, want %d", len(lsNames), len(tree.Entries)) - } - - for i := range lsNames { - if !bytes.Equal(lsNames[i], tree.Entries[i].Name) { - t.Fatalf("ordering mismatch at %d: git=%q parsed=%q", i, lsNames[i], tree.Entries[i].Name) - } - } - - for _, want := range inserted.Entries { - got := tree.Entry(want.Name) - - if got == nil { - t.Fatalf("Entry(%q) returned nil", want.Name) - } - - if got.Mode != want.Mode || got.ID != want.ID { - t.Fatalf("Entry(%q) mismatch", want.Name) - } - } - - if tree.Entry([]byte("does-not-exist")) != nil { - t.Fatalf("Entry on missing name should be nil") - } - }) -} diff --git a/object/tree_serialize.go b/object/tree_serialize.go deleted file mode 100644 index 849738a9..00000000 --- a/object/tree_serialize.go +++ /dev/null @@ -1,55 +0,0 @@ -package object - -import ( - "errors" - "strconv" - - objectheader "codeberg.org/lindenii/furgit/object/header" - objecttype "codeberg.org/lindenii/furgit/object/type" -) - -// SerializeWithoutHeader renders the raw tree body bytes. -func (tree *Tree) SerializeWithoutHeader() ([]byte, error) { - 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, nil -} - -// SerializeWithHeader renders the raw object (header + body). -func (tree *Tree) SerializeWithHeader() ([]byte, error) { - body, err := tree.SerializeWithoutHeader() - if err != nil { - return nil, err - } - - header, ok := objectheader.Encode(objecttype.TypeTree, int64(len(body))) - if !ok { - return nil, errors.New("object: tree: 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/tree_serialize_test.go b/object/tree_serialize_test.go deleted file mode 100644 index 26f8768e..00000000 --- a/object/tree_serialize_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package object_test - -import ( - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - "codeberg.org/lindenii/furgit/object" - objectid "codeberg.org/lindenii/furgit/object/id" -) - -func TestTreeSerialize(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}) - entries := adversarialRootEntries(t, testRepo) - tree := &object.Tree{} - - for i := len(entries) - 1; i >= 0; i-- { - err := tree.InsertEntry(entries[i]) - if err != nil { - t.Fatalf("InsertEntry(%q): %v", entries[i].Name, err) - } - } - - if len(tree.Entries) < 32 { - t.Fatalf("expected at least 32 entries, got %d", len(tree.Entries)) - } - - dup := tree.Entries[0] - - err := tree.InsertEntry(dup) - if err == nil { - t.Fatalf("duplicate InsertEntry should fail") - } - - removed := tree.Entries[len(tree.Entries)/2] - - err = tree.RemoveEntry(removed.Name) - if err != nil { - t.Fatalf("RemoveEntry(%q): %v", removed.Name, err) - } - - if tree.Entry(removed.Name) != nil { - t.Fatalf("Entry(%q) should be nil after remove", removed.Name) - } - - err = tree.RemoveEntry([]byte("no-such-entry")) - if err == nil { - t.Fatalf("RemoveEntry missing entry should fail") - } - - err = tree.InsertEntry(removed) - if err != nil { - t.Fatalf("re-InsertEntry(%q): %v", removed.Name, err) - } - - if tree.Entry(removed.Name) == nil { - t.Fatalf("Entry(%q) should exist after reinsert", removed.Name) - } - - wantTreeID := testRepo.Mktree(t, buildGitMktreeInput(tree.Entries)) - - rawObj, err := tree.SerializeWithHeader() - if err != nil { - t.Fatalf("SerializeWithHeader: %v", err) - } - - gotTreeID := algo.Sum(rawObj) - if gotTreeID != wantTreeID { - t.Fatalf("tree id mismatch: got %s want %s", gotTreeID, wantTreeID) - } - }) -} -- cgit v1.3.1-10-gc9f91