shithub: furgit

Download patch

ref: 27ee98e7a9a8693db59e755739e10c1c1ba852b4
parent: 1b47ebd347533d017235cacd9fe7ae7e215c6ee6
author: Runxi Yu <me@runxiyu.org>
date: Fri Feb 20 22:46:50 EST 2026

objectstore/loose: Add loose backend

--- /dev/null
+++ b/objectstore/loose/parse.go
@@ -1,0 +1,45 @@
+package loose
+
+import (
+	"bufio"
+	"compress/zlib"
+	"errors"
+	"io"
+	"os"
+
+	"codeberg.org/lindenii/furgit/objectheader"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// decodeAll inflates the full loose object payload from file.
+func decodeAll(file *os.File) ([]byte, error) {
+	zr, err := zlib.NewReader(file)
+	if err != nil {
+		return nil, err
+	}
+	defer func() { _ = zr.Close() }()
+	return io.ReadAll(zr)
+}
+
+// parseRaw parses a loose object payload in "type size\0content" format.
+func parseRaw(raw []byte) (objecttype.Type, []byte, error) {
+	ty, _, headerLen, ok := objectheader.Parse(raw)
+	if !ok {
+		return objecttype.TypeInvalid, nil, errors.New("objectstore/loose: malformed object header")
+	}
+	return ty, raw[headerLen:], nil
+}
+
+// readHeader reads and parses a loose object header from br.
+// br must be positioned at the start of decoded loose object bytes.
+func readHeader(br *bufio.Reader) (objecttype.Type, int64, error) {
+	header, err := br.ReadSlice(0)
+	if err != nil {
+		return objecttype.TypeInvalid, 0, err
+	}
+	ty, size, _, ok := objectheader.Parse(header)
+	if !ok {
+		return objecttype.TypeInvalid, 0, errors.New("objectstore/loose: malformed object header")
+	}
+	return ty, size, nil
+}
--- /dev/null
+++ b/objectstore/loose/paths.go
@@ -1,0 +1,41 @@
+package loose
+
+import (
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"path"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objectstore"
+)
+
+// objectPath returns the loose object path for id.
+func (store *Store) objectPath(id objectid.ObjectID) (string, error) {
+	if id.Algorithm() != store.algo {
+		return "", fmt.Errorf("objectstore/loose: object id algorithm mismatch: got %s want %s", id.Algorithm(), store.algo)
+	}
+	hex := id.String()
+	if len(hex) != store.algo.HexLen() {
+		return "", fmt.Errorf("objectstore/loose: malformed object id %q", hex)
+	}
+	return path.Join("objects", hex[:2], hex[2:]), nil
+}
+
+// openObject opens the loose object file for id.
+// Missing files cause objectstore.ErrObjectNotFound.
+func (store *Store) openObject(id objectid.ObjectID) (*os.File, error) {
+	relPath, err := store.objectPath(id)
+	if err != nil {
+		return nil, err
+	}
+	file, err := store.root.Open(relPath)
+	if err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			return nil, objectstore.ErrObjectNotFound
+		}
+		return nil, err
+	}
+	return file, nil
+}
--- /dev/null
+++ b/objectstore/loose/read_bytes.go
@@ -1,0 +1,29 @@
+package loose
+
+import (
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// ReadBytesFull reads a full serialized object as "type size\0content".
+func (store *Store) ReadBytesFull(id objectid.ObjectID) ([]byte, error) {
+	file, err := store.openObject(id)
+	if err != nil {
+		return nil, err
+	}
+	defer func() { _ = file.Close() }()
+	return decodeAll(file)
+}
+
+// ReadBytesContent reads an object's type and content bytes.
+func (store *Store) ReadBytesContent(id objectid.ObjectID) (objecttype.Type, []byte, error) {
+	raw, err := store.ReadBytesFull(id)
+	if err != nil {
+		return objecttype.TypeInvalid, nil, err
+	}
+	ty, content, err := parseRaw(raw)
+	if err != nil {
+		return objecttype.TypeInvalid, nil, err
+	}
+	return ty, content, nil
+}
--- /dev/null
+++ b/objectstore/loose/read_header.go
@@ -1,0 +1,30 @@
+package loose
+
+import (
+	"bufio"
+	"compress/zlib"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+// ReadHeader reads an object's type and declared content length.
+func (store *Store) ReadHeader(id objectid.ObjectID) (objecttype.Type, int64, error) {
+	file, err := store.openObject(id)
+	if err != nil {
+		return objecttype.TypeInvalid, 0, err
+	}
+	defer func() { _ = file.Close() }()
+
+	zr, err := zlib.NewReader(file)
+	if err != nil {
+		return objecttype.TypeInvalid, 0, err
+	}
+	defer func() { _ = zr.Close() }()
+
+	ty, size, err := readHeader(bufio.NewReader(zr))
+	if err != nil {
+		return objecttype.TypeInvalid, 0, err
+	}
+	return ty, size, nil
+}
--- /dev/null
+++ b/objectstore/loose/read_reader.go
@@ -1,0 +1,84 @@
+package loose
+
+import (
+	"bufio"
+	"compress/zlib"
+	"errors"
+	"io"
+	"os"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/objecttype"
+)
+
+type objectReader struct {
+	// reader is the stream exposed by Read. It may be the raw zlib reader
+	// (full object) or a buffered reader positioned at content bytes only.
+	reader io.Reader
+	// file is the underlying loose object file and is closed by Close.
+	file *os.File
+	// zr is the zlib decoder and is closed by Close.
+	zr io.ReadCloser
+}
+
+func (reader *objectReader) Read(dst []byte) (int, error) {
+	return reader.reader.Read(dst)
+}
+
+func (reader *objectReader) Close() error {
+	errZlib := reader.zr.Close()
+	errFile := reader.file.Close()
+	return errors.Join(errZlib, errFile)
+}
+
+// openInflated opens and zlib-decodes a loose object file.
+// The caller owns both returned closers and must close them.
+func (store *Store) openInflated(id objectid.ObjectID) (*os.File, io.ReadCloser, error) {
+	file, err := store.openObject(id)
+	if err != nil {
+		return nil, nil, err
+	}
+	zr, err := zlib.NewReader(file)
+	if err != nil {
+		_ = file.Close()
+		return nil, nil, err
+	}
+	return file, zr, nil
+}
+
+// ReadReaderFull reads a full serialized object stream as "type size\\x00content".
+// The caller must close the returned reader.
+func (store *Store) ReadReaderFull(id objectid.ObjectID) (io.ReadCloser, error) {
+	file, zr, err := store.openInflated(id)
+	if err != nil {
+		return nil, err
+	}
+	return &objectReader{
+		reader: zr,
+		file:   file,
+		zr:     zr,
+	}, nil
+}
+
+// ReadReaderContent reads an object's type, declared content length, and content stream.
+// The caller must close the returned reader.
+func (store *Store) ReadReaderContent(id objectid.ObjectID) (objecttype.Type, int64, io.ReadCloser, error) {
+	file, zr, err := store.openInflated(id)
+	if err != nil {
+		return objecttype.TypeInvalid, 0, nil, err
+	}
+
+	br := bufio.NewReader(zr)
+	ty, size, err := readHeader(br)
+	if err != nil {
+		_ = zr.Close()
+		_ = file.Close()
+		return objecttype.TypeInvalid, 0, nil, err
+	}
+
+	return ty, size, &objectReader{
+		reader: br,
+		file:   file,
+		zr:     zr,
+	}, nil
+}
--- /dev/null
+++ b/objectstore/loose/store.go
@@ -1,0 +1,40 @@
+// Package loose provides loose-object reads from a repository root.
+package loose
+
+import (
+	"errors"
+	"os"
+
+	"codeberg.org/lindenii/furgit/objectid"
+)
+
+// Store reads loose Git objects from a repository root.
+//
+// Store does not own root. Callers are responsible for closing root.
+type Store struct {
+	// root is the repository root capability used for all object file access.
+	// Store does not own this root.
+	root *os.Root
+	// algo is the expected object ID algorithm for lookups.
+	algo objectid.Algorithm
+}
+
+// New creates a loose-object store rooted at root for algo.
+func New(root *os.Root, algo objectid.Algorithm) (*Store, error) {
+	if root == nil {
+		return nil, errors.New("objectstore/loose: nil root")
+	}
+	if algo.Size() == 0 {
+		return nil, objectid.ErrInvalidAlgorithm
+	}
+	return &Store{
+		root: root,
+		algo: algo,
+	}, nil
+}
+
+// Close releases resources associated with the backend.
+func (store *Store) Close() error {
+	_ = store
+	return nil
+}
--