shithub: furgit

Download patch

ref: d26449f260cb4a02bb6949c315580ea31bc29de8
parent: b5a545a3d883026d61beac5556fec2a45e9ec3d3
author: Runxi Yu <me@runxiyu.org>
date: Fri Feb 20 16:33:39 EST 2026

object: Add tests

--- /dev/null
+++ b/object/blob_parse_test.go
@@ -1,0 +1,27 @@
+package object_test
+
+import (
+	"bytes"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/oid"
+)
+
+func TestBlobParseFromGit(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo oid.Algorithm) {
+		repo := testgit.NewBareRepo(t, algo)
+		body := []byte("hello\nblob\n")
+		blobID := repo.HashObject(t, "blob", body)
+
+		rawBody := repo.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")
+		}
+	})
+}
--- /dev/null
+++ b/object/blob_serialize_test.go
@@ -1,0 +1,27 @@
+package object_test
+
+import (
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/oid"
+)
+
+func TestBlobSerialize(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo oid.Algorithm) {
+		repo := testgit.NewBareRepo(t, algo)
+		body := []byte("hello\nblob\n")
+		wantID := repo.HashObject(t, "blob", body)
+
+		blob := &object.Blob{Data: body}
+		rawObj, err := blob.Serialize()
+		if err != nil {
+			t.Fatalf("Serialize: %v", err)
+		}
+		gotID := algo.Sum(rawObj)
+		if gotID != wantID {
+			t.Fatalf("object id mismatch: got %s want %s", gotID, wantID)
+		}
+	})
+}
--- /dev/null
+++ b/object/commit_parse_test.go
@@ -1,0 +1,38 @@
+package object_test
+
+import (
+	"bytes"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/oid"
+)
+
+func TestCommitParseFromGit(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo oid.Algorithm) {
+		repo := testgit.NewBareRepo(t, algo)
+		_, treeID, commitID := repo.MakeCommit(t, "subject\n\nbody")
+
+		rawBody := repo.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)
+		}
+	})
+}
--- /dev/null
+++ b/object/commit_serialize_test.go
@@ -1,0 +1,31 @@
+package object_test
+
+import (
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/oid"
+)
+
+func TestCommitSerialize(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo oid.Algorithm) {
+		repo := testgit.NewBareRepo(t, algo)
+		_, _, commitID := repo.MakeCommit(t, "subject\n\nbody")
+
+		rawBody := repo.CatFile(t, "commit", commitID)
+		commit, err := object.ParseCommit(rawBody, algo)
+		if err != nil {
+			t.Fatalf("ParseCommit: %v", err)
+		}
+
+		rawObj, err := commit.Serialize()
+		if err != nil {
+			t.Fatalf("Serialize: %v", err)
+		}
+		gotID := algo.Sum(rawObj)
+		if gotID != commitID {
+			t.Fatalf("commit id mismatch: got %s want %s", gotID, commitID)
+		}
+	})
+}
--- /dev/null
+++ b/object/tag_parse_test.go
@@ -1,0 +1,39 @@
+package object_test
+
+import (
+	"bytes"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/oid"
+)
+
+func TestTagParseFromGit(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo oid.Algorithm) {
+		repo := testgit.NewBareRepo(t, algo)
+		_, _, commitID := repo.MakeCommit(t, "subject\n\nbody")
+		tagID := repo.TagAnnotated(t, "v1", commitID, "tag message")
+
+		rawBody := repo.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 != object.TypeCommit {
+			t.Fatalf("tag target type = %v, want %v", tag.TargetType, object.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)
+		}
+	})
+}
--- /dev/null
+++ b/object/tag_serialize_test.go
@@ -1,0 +1,32 @@
+package object_test
+
+import (
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/oid"
+)
+
+func TestTagSerialize(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo oid.Algorithm) {
+		repo := testgit.NewBareRepo(t, algo)
+		_, _, commitID := repo.MakeCommit(t, "subject\n\nbody")
+		tagID := repo.TagAnnotated(t, "v1", commitID, "tag message")
+
+		rawBody := repo.CatFile(t, "tag", tagID)
+		tag, err := object.ParseTag(rawBody, algo)
+		if err != nil {
+			t.Fatalf("ParseTag: %v", err)
+		}
+
+		rawObj, err := tag.Serialize()
+		if err != nil {
+			t.Fatalf("Serialize: %v", err)
+		}
+		gotID := algo.Sum(rawObj)
+		if gotID != tagID {
+			t.Fatalf("tag id mismatch: got %s want %s", gotID, tagID)
+		}
+	})
+}
--- /dev/null
+++ b/object/tree_helpers_test.go
@@ -1,0 +1,125 @@
+package object_test
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+)
+
+func mktreeTypeFromMode(t *testing.T, mode object.FileMode) string {
+	t.Helper()
+	switch mode {
+	case object.FileModeDir:
+		return "tree"
+	case object.FileModeRegular, object.FileModeExecutable, object.FileModeSymlink:
+		return "blob"
+	case object.FileModeGitlink:
+		return "commit"
+	default:
+		t.Fatalf("unsupported file mode: %o", mode)
+		return ""
+	}
+}
+
+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, mktreeTypeFromModeNoTB(e.Mode), e.ID.String(), e.Name)
+	}
+	return b.String()
+}
+
+func mktreeTypeFromModeNoTB(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, repo *testgit.TestRepo) []object.TreeEntry {
+	t.Helper()
+
+	blobA := repo.HashObject(t, "blob", []byte("blob-A\n"))
+	blobB := repo.HashObject(t, "blob", []byte("blob-B\n"))
+	blobC := repo.HashObject(t, "blob", []byte("blob-C\n"))
+
+	subDirA := repo.Mktree(t,
+		fmt.Sprintf("100644 blob %s\tnested-a.txt\n100755 blob %s\trun-a.sh\n", blobA.String(), blobB.String()))
+	subDirB := repo.Mktree(t,
+		fmt.Sprintf("100644 blob %s\tnested-b.txt\n100644 blob %s\tz-last\n", blobB.String(), blobC.String()))
+	subDirC := repo.Mktree(t,
+		fmt.Sprintf("120000 blob %s\tlink-c\n100644 blob %s\tchild\n", blobC.String(), blobA.String()))
+	subDirD := repo.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},
+	}
+}
--- /dev/null
+++ b/object/tree_parse_test.go
@@ -1,0 +1,66 @@
+package object_test
+
+import (
+	"bytes"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/oid"
+)
+
+func TestTreeParseFromGit(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo oid.Algorithm) {
+		repo := testgit.NewBareRepo(t, algo)
+		entries := adversarialRootEntries(t, repo)
+		inserted := &object.Tree{}
+		for _, entry := range entries {
+			if err := inserted.InsertEntry(entry); err != nil {
+				t.Fatalf("InsertEntry(%q): %v", entry.Name, err)
+			}
+		}
+
+		treeID := repo.Mktree(t, buildGitMktreeInput(inserted.Entries))
+
+		rawBody := repo.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(repo.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")
+		}
+	})
+}
--- /dev/null
+++ b/object/tree_serialize_test.go
@@ -1,0 +1,60 @@
+package object_test
+
+import (
+	"errors"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/object"
+	"codeberg.org/lindenii/furgit/oid"
+)
+
+func TestTreeSerialize(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo oid.Algorithm) {
+		repo := testgit.NewBareRepo(t, algo)
+		entries := adversarialRootEntries(t, repo)
+		tree := &object.Tree{}
+
+		for i := len(entries) - 1; i >= 0; i-- {
+			if err := tree.InsertEntry(entries[i]); 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]
+		if err := tree.InsertEntry(dup); err == nil {
+			t.Fatalf("duplicate InsertEntry should fail")
+		}
+
+		removed := tree.Entries[len(tree.Entries)/2]
+		if err := tree.RemoveEntry(removed.Name); 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)
+		}
+		if err := tree.RemoveEntry([]byte("no-such-entry")); !errors.Is(err, object.ErrNotFound) {
+			t.Fatalf("RemoveEntry missing err = %v, want ErrNotFound", err)
+		}
+		if err := tree.InsertEntry(removed); 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 := repo.Mktree(t, buildGitMktreeInput(tree.Entries))
+
+		rawObj, err := tree.Serialize()
+		if err != nil {
+			t.Fatalf("Serialize: %v", err)
+		}
+		gotTreeID := algo.Sum(rawObj)
+		if gotTreeID != wantTreeID {
+			t.Fatalf("tree id mismatch: got %s want %s", gotTreeID, wantTreeID)
+		}
+	})
+}
--