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