shithub: furgit

Download patch

ref: 3a567b7738b49f4b49cd9efed5e721d9d286fb45
parent: db4ff17d792f37ee51cca25417a916e430466ffa
author: Runxi Yu <me@runxiyu.org>
date: Fri Feb 20 23:43:18 EST 2026

objectstore/loose: Add loose writer in bytes

--- /dev/null
+++ b/objectstore/loose/write_bytes.go
@@ -1,0 +1,123 @@
+package loose
+
+import (
+	"compress/zlib"
+	"crypto/rand"
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+
+	"codeberg.org/lindenii/furgit/objectheader"
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+const tempObjectFilePrefix = "tmp_obj_"
+
+// WriteBytesFull writes a full serialized object as "type size\\x00content".
+func (store *Store) WriteBytesFull(raw []byte) (objectid.ObjectID, error) {
+	var zero objectid.ObjectID
+
+	if _, _, err := parseRaw(raw); err != nil {
+		return zero, err
+	}
+
+	id := store.algo.Sum(raw)
+	relPath, err := store.objectPath(id)
+	if err != nil {
+		return zero, err
+	}
+	if err := store.writeCompressedAtomic(relPath, raw); err != nil {
+		return zero, err
+	}
+	return id, nil
+}
+
+// WriteBytesContent writes typed content bytes as a loose object.
+func (store *Store) WriteBytesContent(ty objecttype.Type, content []byte) (objectid.ObjectID, error) {
+	var zero objectid.ObjectID
+
+	header, ok := objectheader.Encode(ty, int64(len(content)))
+	if !ok {
+		return zero, fmt.Errorf("objectstore/loose: failed to encode object header for type %d", ty)
+	}
+
+	raw := make([]byte, len(header)+len(content))
+	copy(raw, header)
+	copy(raw[len(header):], content)
+	return store.WriteBytesFull(raw)
+}
+
+// writeCompressedAtomic compresses raw and writes it to relPath atomically.
+func (store *Store) writeCompressedAtomic(relPath string, raw []byte) error {
+	if _, err := store.root.Stat(relPath); err == nil {
+		return nil
+	} else if !errors.Is(err, fs.ErrNotExist) {
+		return err
+	}
+
+	dir := filepath.Dir(relPath)
+	if err := store.root.MkdirAll(dir, 0o755); err != nil {
+		return err
+	}
+
+	tmpRelPath, tmpFile, err := store.createTempObjectFile(dir)
+	if err != nil {
+		return err
+	}
+
+	cleanup := true
+	defer func() {
+		if tmpFile != nil {
+			_ = tmpFile.Close()
+		}
+		if cleanup {
+			_ = store.root.Remove(tmpRelPath)
+		}
+	}()
+
+	zw := zlib.NewWriter(tmpFile)
+	if _, err := zw.Write(raw); err != nil {
+		_ = zw.Close()
+		return err
+	}
+	if err := zw.Close(); err != nil {
+		return err
+	}
+	if err := tmpFile.Sync(); err != nil {
+		return err
+	}
+	if err := tmpFile.Close(); err != nil {
+		return err
+	}
+	tmpFile = nil
+
+	if err := store.root.Rename(tmpRelPath, relPath); err != nil {
+		if errors.Is(err, fs.ErrExist) {
+			return nil
+		}
+		return err
+	}
+
+	cleanup = false
+	return nil
+}
+
+// createTempObjectFile creates a unique temporary object file within dir.
+func (store *Store) createTempObjectFile(dir string) (string, *os.File, error) {
+	for range 16 {
+		relPath := filepath.Join(dir, tempObjectFilePrefix+rand.Text())
+		file, err := store.root.OpenFile(relPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
+		if err == nil {
+			return relPath, file, nil
+		}
+		if errors.Is(err, fs.ErrExist) {
+			continue
+		}
+		return "", nil, err
+	}
+
+	return "", nil, errors.New("objectstore/loose: failed to create temporary object file")
+}
--- /dev/null
+++ b/objectstore/loose/write_test.go
@@ -1,0 +1,93 @@
+package loose_test
+
+import (
+	"bytes"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/objectheader"
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+func TestLooseStoreWriteBytesContentAgainstGit(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		testRepo := testgit.NewBareRepo(t, algo)
+		store := openLooseStore(t, testRepo.Dir(), algo)
+
+		content := []byte("written-by-loose-store\n")
+		expectedHex := testRepo.RunInput(t, content, "hash-object", "-t", "blob", "--stdin")
+		expectedID, err := objectid.ParseHex(algo, expectedHex)
+		if err != nil {
+			t.Fatalf("ParseHex(expected): %v", err)
+		}
+
+		writtenID, err := store.WriteBytesContent(objecttype.TypeBlob, content)
+		if err != nil {
+			t.Fatalf("WriteBytesContent: %v", err)
+		}
+		if writtenID != expectedID {
+			t.Fatalf("WriteBytesContent id = %s, want %s", writtenID, expectedID)
+		}
+
+		gotBody := testRepo.CatFile(t, "blob", writtenID)
+		if !bytes.Equal(gotBody, content) {
+			t.Fatalf("git cat-file body mismatch")
+		}
+
+		writtenID2, err := store.WriteBytesContent(objecttype.TypeBlob, content)
+		if err != nil {
+			t.Fatalf("WriteBytesContent second write: %v", err)
+		}
+		if writtenID2 != expectedID {
+			t.Fatalf("WriteBytesContent second id = %s, want %s", writtenID2, expectedID)
+		}
+	})
+}
+
+func TestLooseStoreWriteBytesFullAgainstGit(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		testRepo := testgit.NewBareRepo(t, algo)
+		store := openLooseStore(t, testRepo.Dir(), algo)
+
+		body := []byte("full-write-body\n")
+		header, ok := objectheader.Encode(objecttype.TypeBlob, int64(len(body)))
+		if !ok {
+			t.Fatalf("objectheader.Encode failed")
+		}
+		raw := make([]byte, len(header)+len(body))
+		copy(raw, header)
+		copy(raw[len(header):], body)
+
+		wantID := algo.Sum(raw)
+		gotID, err := store.WriteBytesFull(raw)
+		if err != nil {
+			t.Fatalf("WriteBytesFull: %v", err)
+		}
+		if gotID != wantID {
+			t.Fatalf("WriteBytesFull id = %s, want %s", gotID, wantID)
+		}
+
+		gotBody := testRepo.CatFile(t, "blob", gotID)
+		if !bytes.Equal(gotBody, body) {
+			t.Fatalf("git cat-file body mismatch")
+		}
+	})
+}
+
+func TestLooseStoreWriteValidationErrors(t *testing.T) {
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+		testRepo := testgit.NewBareRepo(t, algo)
+		store := openLooseStore(t, testRepo.Dir(), algo)
+
+		if _, err := store.WriteBytesFull([]byte("blob 1\x00hello")); err == nil {
+			t.Fatalf("WriteBytesFull expected size/content mismatch error")
+		}
+		if _, err := store.WriteBytesFull([]byte("not-a-header")); err == nil {
+			t.Fatalf("WriteBytesFull expected malformed header error")
+		}
+		if _, err := store.WriteBytesContent(objecttype.TypeInvalid, []byte("x")); err == nil {
+			t.Fatalf("WriteBytesContent expected invalid type error")
+		}
+	})
+}
--