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")+ }
+ })
+}
--
⑨