shithub: furgit

Download patch

ref: 3ecd35180fa8cb842589e28744fed7d130120dc1
parent: 1fdcdc4d8a160f4b2b48be10f6eef2235b99f8f6
author: Runxi Yu <me@runxiyu.org>
date: Sat Feb 21 12:49:18 EST 2026

objectstore/loose, repository: Use a Reader-based API

--- a/objectstore/loose/write_bytes.go
+++ b/objectstore/loose/write_bytes.go
@@ -7,38 +7,12 @@
 	"codeberg.org/lindenii/furgit/objecttype"
 )
 
-// WriteBytesFull writes a full serialized object as "type size\\x00content".
+// WriteBytesFull writes a full serialized object as "type size\0content".
 func (store *Store) WriteBytesFull(raw []byte) (objectid.ObjectID, error) {
-	var zero objectid.ObjectID
-
-	writer, finalize, err := store.WriteWriterFull()
-	if err != nil {
-		return zero, err
-	}
-	if _, err := bytes.NewReader(raw).WriteTo(writer); err != nil {
-		_ = writer.Close()
-		return zero, err
-	}
-	if err := writer.Close(); err != nil {
-		return zero, err
-	}
-	return finalize()
+	return store.WriteReaderFull(bytes.NewReader(raw))
 }
 
 // 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
-
-	writer, finalize, err := store.WriteWriterContent(ty, int64(len(content)))
-	if err != nil {
-		return zero, err
-	}
-	if _, err := bytes.NewReader(content).WriteTo(writer); err != nil {
-		_ = writer.Close()
-		return zero, err
-	}
-	if err := writer.Close(); err != nil {
-		return zero, err
-	}
-	return finalize()
+	return store.WriteReaderContent(ty, int64(len(content)), bytes.NewReader(content))
 }
--- /dev/null
+++ b/objectstore/loose/write_reader.go
@@ -1,0 +1,70 @@
+package loose
+
+import (
+	"fmt"
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectheader"
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// WriteReaderContent writes one loose object from typed content bytes read from src.
+// src must provide exactly size bytes.
+// size is required because loose object headers are "type size\0content", so the
+// header must be emitted before streaming content without buffering.
+func (store *Store) WriteReaderContent(ty objecttype.Type, size int64, src io.Reader) (objectid.ObjectID, error) {
+	if size < 0 {
+		return objectid.ObjectID{}, fmt.Errorf("objectstore/loose: negative content size: %d", size)
+	}
+
+	header, ok := objectheader.Encode(ty, size)
+	if !ok {
+		return objectid.ObjectID{}, fmt.Errorf("objectstore/loose: failed to encode object header for type %v", ty)
+	}
+
+	writer, err := store.newStreamWriter(false)
+	if err != nil {
+		return objectid.ObjectID{}, err
+	}
+	writer.headerDone = true
+	writer.expectedContentLeft = size
+
+	if err := writer.writeRawChunk(header); err != nil {
+		_ = writer.Close()
+		_ = store.root.Remove(writer.tmpRelPath)
+		return objectid.ObjectID{}, err
+	}
+
+	return writeReaderIntoStreamWriter(writer, src)
+}
+
+// WriteReaderFull writes one loose object from raw bytes "type size\0content"
+// read from src.
+func (store *Store) WriteReaderFull(src io.Reader) (objectid.ObjectID, error) {
+	writer, err := store.newStreamWriter(true)
+	if err != nil {
+		return objectid.ObjectID{}, err
+	}
+	return writeReaderIntoStreamWriter(writer, src)
+}
+
+// writeReaderIntoStreamWriter copies src into writer and publishes the object.
+func writeReaderIntoStreamWriter(writer *streamWriter, src io.Reader) (objectid.ObjectID, error) {
+	if _, err := io.Copy(writer, src); err != nil {
+		_ = writer.Close()
+		_ = writer.store.root.Remove(writer.tmpRelPath)
+		return objectid.ObjectID{}, err
+	}
+	if err := writer.Close(); err != nil {
+		_ = writer.store.root.Remove(writer.tmpRelPath)
+		return objectid.ObjectID{}, err
+	}
+
+	id, err := writer.finalize()
+	if err != nil {
+		_ = writer.store.root.Remove(writer.tmpRelPath)
+		return objectid.ObjectID{}, err
+	}
+	return id, nil
+}
--- a/objectstore/loose/write_test.go
+++ b/objectstore/loose/write_test.go
@@ -2,7 +2,6 @@
 
 import (
 	"bytes"
-	"io"
 	"testing"
 
 	"codeberg.org/lindenii/furgit/internal/testgit"
@@ -11,13 +10,13 @@
 	"codeberg.org/lindenii/furgit/objecttype"
 )
 
-func TestLooseStoreWriteWriterContentAgainstGit(t *testing.T) {
+func TestLooseStoreWriteReaderContentAgainstGit(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})
 		store := openLooseStore(t, testRepo.Dir(), algo)
 
-		content := []byte("written-by-content-writer\n")
+		content := []byte("written-by-content-reader\n")
 		expectedHex := testRepo.RunInput(t, content, "hash-object", "-t", "blob", "--stdin")
 		expectedID, err := objectid.ParseHex(algo, expectedHex)
 		if err != nil {
@@ -24,22 +23,12 @@
 			t.Fatalf("ParseHex(expected): %v", err)
 		}
 
-		writer, finalize, err := store.WriteWriterContent(objecttype.TypeBlob, int64(len(content)))
+		writtenID, err := store.WriteReaderContent(objecttype.TypeBlob, int64(len(content)), bytes.NewReader(content))
 		if err != nil {
-			t.Fatalf("WriteWriterContent: %v", err)
+			t.Fatalf("WriteReaderContent: %v", err)
 		}
-		if _, err := io.Copy(writer, bytes.NewReader(content)); err != nil {
-			t.Fatalf("WriteWriterContent write: %v", err)
-		}
-		if err := writer.Close(); err != nil {
-			t.Fatalf("WriteWriterContent close: %v", err)
-		}
-		writtenID, err := finalize()
-		if err != nil {
-			t.Fatalf("WriteWriterContent finalize: %v", err)
-		}
 		if writtenID != expectedID {
-			t.Fatalf("WriteWriterContent id = %s, want %s", writtenID, expectedID)
+			t.Fatalf("WriteReaderContent id = %s, want %s", writtenID, expectedID)
 		}
 
 		gotBody := testRepo.CatFile(t, "blob", writtenID)
@@ -48,33 +37,23 @@
 		}
 
 		// Writing the same object again should succeed and return the same ID.
-		writer, finalize, err = store.WriteWriterContent(objecttype.TypeBlob, int64(len(content)))
+		writtenID2, err := store.WriteReaderContent(objecttype.TypeBlob, int64(len(content)), bytes.NewReader(content))
 		if err != nil {
-			t.Fatalf("WriteWriterContent second: %v", err)
+			t.Fatalf("WriteReaderContent second: %v", err)
 		}
-		if _, err := io.Copy(writer, bytes.NewReader(content)); err != nil {
-			t.Fatalf("WriteWriterContent second write: %v", err)
-		}
-		if err := writer.Close(); err != nil {
-			t.Fatalf("WriteWriterContent second close: %v", err)
-		}
-		writtenID2, err := finalize()
-		if err != nil {
-			t.Fatalf("WriteWriterContent second finalize: %v", err)
-		}
 		if writtenID2 != expectedID {
-			t.Fatalf("WriteWriterContent second id = %s, want %s", writtenID2, expectedID)
+			t.Fatalf("WriteReaderContent second id = %s, want %s", writtenID2, expectedID)
 		}
 	})
 }
 
-func TestLooseStoreWriteWriterFullAgainstGit(t *testing.T) {
+func TestLooseStoreWriteReaderFullAgainstGit(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})
 		store := openLooseStore(t, testRepo.Dir(), algo)
 
-		body := []byte("full-writer-body\n")
+		body := []byte("full-reader-body\n")
 		header, ok := objectheader.Encode(objecttype.TypeBlob, int64(len(body)))
 		if !ok {
 			t.Fatalf("objectheader.Encode failed")
@@ -84,22 +63,12 @@
 		copy(raw[len(header):], body)
 
 		wantID := algo.Sum(raw)
-		writer, finalize, err := store.WriteWriterFull()
+		gotID, err := store.WriteReaderFull(bytes.NewReader(raw))
 		if err != nil {
-			t.Fatalf("WriteWriterFull: %v", err)
+			t.Fatalf("WriteReaderFull: %v", err)
 		}
-		if _, err := io.Copy(writer, bytes.NewReader(raw)); err != nil {
-			t.Fatalf("WriteWriterFull write: %v", err)
-		}
-		if err := writer.Close(); err != nil {
-			t.Fatalf("WriteWriterFull close: %v", err)
-		}
-		gotID, err := finalize()
-		if err != nil {
-			t.Fatalf("WriteWriterFull finalize: %v", err)
-		}
 		if gotID != wantID {
-			t.Fatalf("WriteWriterFull id = %s, want %s", gotID, wantID)
+			t.Fatalf("WriteReaderFull id = %s, want %s", gotID, wantID)
 		}
 
 		gotBody := testRepo.CatFile(t, "blob", gotID)
@@ -109,7 +78,7 @@
 	})
 }
 
-func TestLooseStoreWriterValidationErrors(t *testing.T) {
+func TestLooseStoreReaderValidationErrors(t *testing.T) {
 	t.Parallel()
 	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
 		t.Run("content overflow", func(t *testing.T) {
@@ -117,17 +86,9 @@
 			testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
 			store := openLooseStore(t, testRepo.Dir(), algo)
 
-			writer, finalize, err := store.WriteWriterContent(objecttype.TypeBlob, 1)
-			if err != nil {
-				t.Fatalf("WriteWriterContent: %v", err)
+			if _, err := store.WriteReaderContent(objecttype.TypeBlob, 1, bytes.NewReader([]byte("hello"))); err == nil {
+				t.Fatalf("expected error after overflow")
 			}
-			if _, err := writer.Write([]byte("hello")); err == nil {
-				t.Fatalf("expected overflow error")
-			}
-			_ = writer.Close()
-			if _, err := finalize(); err == nil {
-				t.Fatalf("expected finalize error after overflow")
-			}
 		})
 
 		t.Run("content short", func(t *testing.T) {
@@ -135,19 +96,9 @@
 			testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
 			store := openLooseStore(t, testRepo.Dir(), algo)
 
-			writer, finalize, err := store.WriteWriterContent(objecttype.TypeBlob, 5)
-			if err != nil {
-				t.Fatalf("WriteWriterContent: %v", err)
+			if _, err := store.WriteReaderContent(objecttype.TypeBlob, 5, bytes.NewReader([]byte("x"))); err == nil {
+				t.Fatalf("expected error for short content")
 			}
-			if _, err := writer.Write([]byte("x")); err != nil {
-				t.Fatalf("write short: %v", err)
-			}
-			if err := writer.Close(); err != nil {
-				t.Fatalf("close short: %v", err)
-			}
-			if _, err := finalize(); err == nil {
-				t.Fatalf("expected finalize error for short content")
-			}
 		})
 
 		t.Run("full malformed header", func(t *testing.T) {
@@ -155,19 +106,9 @@
 			testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
 			store := openLooseStore(t, testRepo.Dir(), algo)
 
-			writer, finalize, err := store.WriteWriterFull()
-			if err != nil {
-				t.Fatalf("WriteWriterFull: %v", err)
+			if _, err := store.WriteReaderFull(bytes.NewReader([]byte("not-a-header"))); err == nil {
+				t.Fatalf("expected error for malformed header")
 			}
-			if _, err := writer.Write([]byte("not-a-header")); err != nil {
-				t.Fatalf("write malformed header bytes unexpectedly failed: %v", err)
-			}
-			if err := writer.Close(); err != nil {
-				t.Fatalf("close malformed header: %v", err)
-			}
-			if _, err := finalize(); err == nil {
-				t.Fatalf("expected finalize error for malformed header")
-			}
 		})
 
 		t.Run("full size mismatch", func(t *testing.T) {
@@ -175,17 +116,9 @@
 			testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
 			store := openLooseStore(t, testRepo.Dir(), algo)
 
-			writer, finalize, err := store.WriteWriterFull()
-			if err != nil {
-				t.Fatalf("WriteWriterFull: %v", err)
-			}
 			raw := []byte("blob 1\x00hello")
-			if _, err := io.Copy(writer, bytes.NewReader(raw)); err == nil {
-				t.Fatalf("expected overflow error")
-			}
-			_ = writer.Close()
-			if _, err := finalize(); err == nil {
-				t.Fatalf("expected finalize error after mismatch")
+			if _, err := store.WriteReaderFull(bytes.NewReader(raw)); err == nil {
+				t.Fatalf("expected error after mismatch")
 			}
 		})
 	})
--- a/objectstore/loose/write_writer.go
+++ b/objectstore/loose/write_writer.go
@@ -5,9 +5,7 @@
 	"compress/zlib"
 	"crypto/rand"
 	"errors"
-	"fmt"
 	"hash"
-	"io"
 	"io/fs"
 	"os"
 	"path/filepath"
@@ -14,49 +12,10 @@
 
 	"codeberg.org/lindenii/furgit/objectheader"
 	"codeberg.org/lindenii/furgit/objectid"
-	"codeberg.org/lindenii/furgit/objecttype"
 )
 
 const tempObjectFilePrefix = "tmp_obj_"
 
-// WriteWriterContent returns a writer for object content bytes.
-// The writer accepts exactly size bytes. After closing the writer,
-// call finalize to atomically publish the loose object and get its ID.
-func (store *Store) WriteWriterContent(ty objecttype.Type, size int64) (io.WriteCloser, func() (objectid.ObjectID, error), error) {
-	if size < 0 {
-		return nil, nil, errors.New("objectstore/loose: negative content size")
-	}
-
-	header, ok := objectheader.Encode(ty, size)
-	if !ok {
-		return nil, nil, fmt.Errorf("objectstore/loose: failed to encode object header for type %d", ty)
-	}
-
-	writer, err := store.newStreamWriter(false)
-	if err != nil {
-		return nil, nil, err
-	}
-	writer.headerDone = true
-	writer.expectedContentLeft = size
-	if err := writer.writeRawChunk(header); err != nil {
-		_ = writer.Close()
-		return nil, nil, err
-	}
-
-	return writer, writer.Finalize, nil
-}
-
-// WriteWriterFull returns a writer for full raw object bytes:
-// "type size\0content". After closing the writer, call finalize
-// to atomically publish the loose object and get its ID.
-func (store *Store) WriteWriterFull() (io.WriteCloser, func() (objectid.ObjectID, error), error) {
-	writer, err := store.newStreamWriter(true)
-	if err != nil {
-		return nil, nil, err
-	}
-	return writer, writer.Finalize, nil
-}
-
 // streamWriter incrementally hashes and deflates an object into a temp file.
 // Finalize validates size accounting and atomically renames the temp file.
 type streamWriter struct {
@@ -152,10 +111,10 @@
 	return errors.Join(errZlib, errSync, errFile)
 }
 
-// Finalize validates write completeness and atomically publishes the object.
+// finalize validates write completeness and atomically publishes the object.
 // Publication is no-clobber: it links tmpRelPath to the object path and treats
 // existing destination objects as success.
-func (writer *streamWriter) Finalize() (objectid.ObjectID, error) {
+func (writer *streamWriter) finalize() (objectid.ObjectID, error) {
 	if writer.finalized {
 		return writer.finalID, writer.finalErr
 	}
--- a/repository/write_loose.go
+++ b/repository/write_loose.go
@@ -1,6 +1,7 @@
 package repository
 
 import (
+	"bytes"
 	"fmt"
 	"io"
 
@@ -10,7 +11,7 @@
 
 // WriteLooseBytesFull writes one loose object from raw "type size\0content".
 func (repo *Repository) WriteLooseBytesFull(raw []byte) (objectid.ObjectID, error) {
-	id, err := repo.objectsLooseForWritingOnly.WriteBytesFull(raw)
+	id, err := repo.objectsLooseForWritingOnly.WriteReaderFull(bytes.NewReader(raw))
 	if err != nil {
 		return objectid.ObjectID{}, fmt.Errorf("repository: write loose full bytes: %w", err)
 	}
@@ -19,7 +20,7 @@
 
 // WriteLooseBytesContent writes one loose object from typed content bytes.
 func (repo *Repository) WriteLooseBytesContent(ty objecttype.Type, content []byte) (objectid.ObjectID, error) {
-	id, err := repo.objectsLooseForWritingOnly.WriteBytesContent(ty, content)
+	id, err := repo.objectsLooseForWritingOnly.WriteReaderContent(ty, int64(len(content)), bytes.NewReader(content))
 	if err != nil {
 		return objectid.ObjectID{}, fmt.Errorf("repository: write loose content bytes: %w", err)
 	}
@@ -26,25 +27,22 @@
 	return id, nil
 }
 
-// WriteLooseWriterFull returns a writer for one full serialized object stream.
-//
-// The caller must close the writer, then call finalize to publish the object.
-func (repo *Repository) WriteLooseWriterFull() (io.WriteCloser, func() (objectid.ObjectID, error), error) {
-	writer, finalize, err := repo.objectsLooseForWritingOnly.WriteWriterFull()
+// WriteLooseReaderFull writes one loose object from raw bytes
+// "type size\0content" read from src.
+func (repo *Repository) WriteLooseReaderFull(src io.Reader) (objectid.ObjectID, error) {
+	id, err := repo.objectsLooseForWritingOnly.WriteReaderFull(src)
 	if err != nil {
-		return nil, nil, fmt.Errorf("repository: create loose full writer: %w", err)
+		return objectid.ObjectID{}, fmt.Errorf("repository: write loose full reader: %w", err)
 	}
-	return writer, finalize, nil
+	return id, nil
 }
 
-// WriteLooseWriterContent returns a writer for one typed object content stream.
-//
-// The caller must write exactly size bytes, close the writer, then call
-// finalize to publish the object.
-func (repo *Repository) WriteLooseWriterContent(ty objecttype.Type, size int64) (io.WriteCloser, func() (objectid.ObjectID, error), error) {
-	writer, finalize, err := repo.objectsLooseForWritingOnly.WriteWriterContent(ty, size)
+// WriteLooseReaderContent writes one loose object from typed content bytes read
+// from src. src must provide exactly size bytes.
+func (repo *Repository) WriteLooseReaderContent(ty objecttype.Type, size int64, src io.Reader) (objectid.ObjectID, error) {
+	id, err := repo.objectsLooseForWritingOnly.WriteReaderContent(ty, size, src)
 	if err != nil {
-		return nil, nil, fmt.Errorf("repository: create loose content writer: %w", err)
+		return objectid.ObjectID{}, fmt.Errorf("repository: write loose content reader: %w", err)
 	}
-	return writer, finalize, nil
+	return id, nil
 }
--- a/repository/write_loose_test.go
+++ b/repository/write_loose_test.go
@@ -2,7 +2,6 @@
 
 import (
 	"bytes"
-	"io"
 	"testing"
 
 	"codeberg.org/lindenii/furgit/internal/testgit"
@@ -51,7 +50,7 @@
 	})
 }
 
-func TestWriteLooseWriterContent(t *testing.T) {
+func TestWriteLooseReaderContent(t *testing.T) {
 	t.Parallel()
 
 	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
@@ -67,29 +66,15 @@
 		}
 		defer func() { _ = repo.Close() }()
 
-		content := []byte("write-loose-writer-content\n")
-		writer, finalize, err := repo.WriteLooseWriterContent(objecttype.TypeBlob, int64(len(content)))
+		content := []byte("write-loose-reader-content\n")
+		gotID, err := repo.WriteLooseReaderContent(objecttype.TypeBlob, int64(len(content)), bytes.NewReader(content))
 		if err != nil {
-			t.Fatalf("WriteLooseWriterContent: %v", err)
+			t.Fatalf("WriteLooseReaderContent: %v", err)
 		}
 
-		if _, err := writer.Write(content[:6]); err != nil {
-			t.Fatalf("WriteLooseWriterContent first write: %v", err)
-		}
-		if _, err := writer.Write(content[6:]); err != nil {
-			t.Fatalf("WriteLooseWriterContent second write: %v", err)
-		}
-		if err := writer.Close(); err != nil {
-			t.Fatalf("WriteLooseWriterContent close: %v", err)
-		}
-		gotID, err := finalize()
-		if err != nil {
-			t.Fatalf("WriteLooseWriterContent finalize: %v", err)
-		}
-
 		wantID := repoHarness.HashObject(t, "blob", content)
 		if gotID != wantID {
-			t.Fatalf("WriteLooseWriterContent id = %s, want %s", gotID, wantID)
+			t.Fatalf("WriteLooseReaderContent id = %s, want %s", gotID, wantID)
 		}
 	})
 }
@@ -124,22 +109,12 @@
 			t.Fatalf("WriteLooseBytesFull id = %s, want %s", idFromBytes, commitID)
 		}
 
-		writer, finalize, err := repo.WriteLooseWriterFull()
+		idFromReader, err := repo.WriteLooseReaderFull(bytes.NewReader(raw))
 		if err != nil {
-			t.Fatalf("WriteLooseWriterFull: %v", err)
+			t.Fatalf("WriteLooseReaderFull: %v", err)
 		}
-		if _, err := io.Copy(writer, bytes.NewReader(raw)); err != nil {
-			t.Fatalf("WriteLooseWriterFull copy: %v", err)
-		}
-		if err := writer.Close(); err != nil {
-			t.Fatalf("WriteLooseWriterFull close: %v", err)
-		}
-		idFromWriter, err := finalize()
-		if err != nil {
-			t.Fatalf("WriteLooseWriterFull finalize: %v", err)
-		}
-		if idFromWriter != commitID {
-			t.Fatalf("WriteLooseWriterFull id = %s, want %s", idFromWriter, commitID)
+		if idFromReader != commitID {
+			t.Fatalf("WriteLooseReaderFull id = %s, want %s", idFromReader, commitID)
 		}
 	})
 }
--