shithub: furgit

Download patch

ref: c9eefd50557a5436da84e0a38ee96c812d453336
parent: 673902b14458f32fbf47efa3757279872bdfcb7e
author: Runxi Yu <me@runxiyu.org>
date: Sat Feb 21 10:39:10 EST 2026

repository: Add loose object writing

--- a/repository/repository.go
+++ b/repository/repository.go
@@ -27,8 +27,9 @@
 	config *config.Config
 	algo   objectid.Algorithm
 
-	objects objectstore.Store
-	refs    refstore.Store
+	objects                    objectstore.Store
+	objectsLooseForWritingOnly *objectloose.Store
+	refs                       refstore.Store
 }
 
 // Open opens a repository and wires object/ref stores from its on-disk format.
@@ -58,11 +59,12 @@
 	}
 	repo.algo = algo
 
-	objects, err := openObjectStore(path, algo)
+	objects, objectsLooseForWritingOnly, err := openObjectStore(path, algo)
 	if err != nil {
 		return nil, err
 	}
 	repo.objects = objects
+	repo.objectsLooseForWritingOnly = objectsLooseForWritingOnly
 
 	refs, err := openRefStore(path, algo)
 	if err != nil {
@@ -111,6 +113,11 @@
 			errs = append(errs, err)
 		}
 	}
+	if repo.objectsLooseForWritingOnly != nil {
+		if err := repo.objectsLooseForWritingOnly.Close(); err != nil {
+			errs = append(errs, err)
+		}
+	}
 
 	return errors.Join(errs...)
 }
@@ -141,51 +148,53 @@
 	return algo, nil
 }
 
-func openObjectStore(path string, algo objectid.Algorithm) (out objectstore.Store, err error) {
+func openObjectStore(path string, algo objectid.Algorithm) (objectstore.Store, *objectloose.Store, error) {
 	repoRoot, err := os.OpenRoot(path)
 	if err != nil {
-		return nil, fmt.Errorf("repository: open root: %w", err)
+		return nil, nil, fmt.Errorf("repository: open root: %w", err)
 	}
 	defer func() { _ = repoRoot.Close() }()
 
 	objectsRoot, err := repoRoot.OpenRoot("objects")
 	if err != nil {
-		return nil, fmt.Errorf("repository: open objects: %w", err)
+		return nil, nil, fmt.Errorf("repository: open objects: %w", err)
 	}
-	var packRoot *os.Root
-	defer func() {
-		if err != nil {
-			if out != nil {
-				_ = out.Close()
-			}
-			if packRoot != nil {
-				_ = packRoot.Close()
-			}
-			_ = objectsRoot.Close()
-		}
-	}()
 
 	looseStore, err := objectloose.New(objectsRoot, algo)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	backends := []objectstore.Store{looseStore}
 
-	packRoot, err = objectsRoot.OpenRoot("pack")
+	packRoot, err := objectsRoot.OpenRoot("pack")
 	if err == nil {
 		var packedStore *objectpacked.Store
 		packedStore, err = objectpacked.New(packRoot, algo)
 		if err != nil {
-			return nil, err
+			_ = looseStore.Close()
+			return nil, nil, err
 		}
 		backends = append(backends, packedStore)
 	} else if !errors.Is(err, os.ErrNotExist) {
-		return nil, fmt.Errorf("repository: open objects/pack: %w", err)
+		_ = looseStore.Close()
+		return nil, nil, fmt.Errorf("repository: open objects/pack: %w", err)
 	}
-	err = nil
-	out = objectchain.New(backends...)
 
-	return out, nil
+	objectsChain := objectchain.New(backends...)
+
+	objectsRootForWriting, err := repoRoot.OpenRoot("objects")
+	if err != nil {
+		_ = objectsChain.Close()
+		return nil, nil, fmt.Errorf("repository: open objects for loose writing: %w", err)
+	}
+	objectsLooseForWritingOnly, err := objectloose.New(objectsRootForWriting, algo)
+	if err != nil {
+		_ = objectsRootForWriting.Close()
+		_ = objectsChain.Close()
+		return nil, nil, err
+	}
+
+	return objectsChain, objectsLooseForWritingOnly, nil
 }
 
 func openRefStore(path string, algo objectid.Algorithm) (out refstore.Store, err error) {
--- /dev/null
+++ b/repository/write_loose.go
@@ -1,0 +1,50 @@
+package repository
+
+import (
+	"fmt"
+	"io"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// 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)
+	if err != nil {
+		return objectid.ObjectID{}, fmt.Errorf("repository: write loose full bytes: %w", err)
+	}
+	return id, nil
+}
+
+// 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)
+	if err != nil {
+		return objectid.ObjectID{}, fmt.Errorf("repository: write loose content bytes: %w", err)
+	}
+	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()
+	if err != nil {
+		return nil, nil, fmt.Errorf("repository: create loose full writer: %w", err)
+	}
+	return writer, finalize, 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)
+	if err != nil {
+		return nil, nil, fmt.Errorf("repository: create loose content writer: %w", err)
+	}
+	return writer, finalize, nil
+}
--- /dev/null
+++ b/repository/write_loose_test.go
@@ -1,0 +1,145 @@
+package repository_test
+
+import (
+	"bytes"
+	"io"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+	"codeberg.org/lindenii/furgit/repository"
+)
+
+func TestWriteLooseBytesContent(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		repoHarness := testgit.NewRepo(t, testgit.RepoOptions{
+			ObjectFormat: algo,
+			Bare:         true,
+			RefFormat:    "files",
+		})
+
+		repo, err := repository.Open(repoHarness.Dir())
+		if err != nil {
+			t.Fatalf("repository.Open: %v", err)
+		}
+		defer func() { _ = repo.Close() }()
+
+		content := []byte("write-loose-bytes-content\n")
+		gotID, err := repo.WriteLooseBytesContent(objecttype.TypeBlob, content)
+		if err != nil {
+			t.Fatalf("WriteLooseBytesContent: %v", err)
+		}
+
+		wantID := repoHarness.HashObject(t, "blob", content)
+		if gotID != wantID {
+			t.Fatalf("WriteLooseBytesContent id = %s, want %s", gotID, wantID)
+		}
+
+		ty, gotContent, err := repo.ReadStoredBytesContent(gotID)
+		if err != nil {
+			t.Fatalf("ReadStoredBytesContent: %v", err)
+		}
+		if ty != objecttype.TypeBlob {
+			t.Fatalf("ReadStoredBytesContent type = %v, want %v", ty, objecttype.TypeBlob)
+		}
+		if !bytes.Equal(gotContent, content) {
+			t.Fatalf("ReadStoredBytesContent content mismatch")
+		}
+	})
+}
+
+func TestWriteLooseWriterContent(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		repoHarness := testgit.NewRepo(t, testgit.RepoOptions{
+			ObjectFormat: algo,
+			Bare:         true,
+			RefFormat:    "files",
+		})
+
+		repo, err := repository.Open(repoHarness.Dir())
+		if err != nil {
+			t.Fatalf("repository.Open: %v", err)
+		}
+		defer func() { _ = repo.Close() }()
+
+		content := []byte("write-loose-writer-content\n")
+		writer, finalize, err := repo.WriteLooseWriterContent(objecttype.TypeBlob, int64(len(content)))
+		if err != nil {
+			t.Fatalf("WriteLooseWriterContent: %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)
+		}
+	})
+}
+
+func TestWriteLooseFull(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		repoHarness := testgit.NewRepo(t, testgit.RepoOptions{
+			ObjectFormat: algo,
+			Bare:         true,
+			RefFormat:    "files",
+		})
+		_, _, commitID := repoHarness.MakeCommit(t, "write-loose-full")
+
+		repo, err := repository.Open(repoHarness.Dir())
+		if err != nil {
+			t.Fatalf("repository.Open: %v", err)
+		}
+		defer func() { _ = repo.Close() }()
+
+		raw, err := repo.ReadStoredBytesFull(commitID)
+		if err != nil {
+			t.Fatalf("ReadStoredBytesFull: %v", err)
+		}
+
+		idFromBytes, err := repo.WriteLooseBytesFull(raw)
+		if err != nil {
+			t.Fatalf("WriteLooseBytesFull: %v", err)
+		}
+		if idFromBytes != commitID {
+			t.Fatalf("WriteLooseBytesFull id = %s, want %s", idFromBytes, commitID)
+		}
+
+		writer, finalize, err := repo.WriteLooseWriterFull()
+		if err != nil {
+			t.Fatalf("WriteLooseWriterFull: %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)
+		}
+	})
+}
--