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