gitmote

gitmote  ·  history  ·  branches

feat: s3 object store — Store interface, S3 and in-memory impls

66376438e9662d6b9fc7e864336ec5364c6e64cb
Atanas Minev <atmin@protonmail.com> · 2026-07-02 16:02

diff --git a/go.mod b/go.mod
index a1f5e1e..9c1ed41 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,28 @@
module github.com/atmin/gitmote
go 1.26
+
+require (
+ github.com/aws/aws-sdk-go-v2 v1.42.1
+ github.com/aws/aws-sdk-go-v2/config v1.32.27
+ github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.13
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2
+)
+
+require (
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.26 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
+ github.com/aws/smithy-go v1.27.3 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..768400a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,38 @@
+github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
+github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
+github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI=
+github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
+github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.13 h1:zHi0z+ZT207/QPyaCmTr4JksQ0Ty0DM+WXW0xsfckXQ=
+github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.13/go.mod h1:ycxBs1ztF7RbxNWiJoIegPCvsfuO7dCvtyyloQvRgNs=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2 h1:bAY6O/TDv1HQnvylh9E247IyIKsUWUt2G965S7qX110=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
+github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 h1:69JEZSDTQ+UNbTWQJCZMmbpQb5sfc79KUt0O7Pyfjmo=
+github.com/aws/aws-sdk-go-v2/service/signin v1.2.2/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
+github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 h1:xlK3Tdc8FO7Tq1k0+hL+otF33glj+dE+qeM5iINiDvU=
+github.com/aws/aws-sdk-go-v2/service/sso v1.31.5/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 h1:yX1IbiBfC7SdEgDwIGnRaZyPPDRbQPDOJxl8102PcGk=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
+github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 h1:T3ANO8QWDbzQD8f4+UaX+fvJlyGnOFMKLbW+NGBHg04=
+github.com/aws/aws-sdk-go-v2/service/sts v1.43.5/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
+github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
+github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
diff --git a/internal/store/conformance_test.go b/internal/store/conformance_test.go
new file mode 100644
index 0000000..e81076b
--- /dev/null
+++ b/internal/store/conformance_test.go
@@ -0,0 +1,151 @@
+package store
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "slices"
+ "testing"
+)
+
+// testConformance is the shared Store contract suite. Every implementation
+// must pass it — mem and s3 stay in lockstep by construction. newStore must
+// return an empty store with an isolated keyspace.
+func testConformance(t *testing.T, newStore func(t *testing.T) Store) {
+ ctx := context.Background()
+
+ put := func(t *testing.T, s Store, key string, content []byte) {
+ t.Helper()
+ if err := s.Put(ctx, key, bytes.NewReader(content)); err != nil {
+ t.Fatalf("Put(%s): %v", key, err)
+ }
+ }
+ get := func(t *testing.T, s Store, key string) []byte {
+ t.Helper()
+ rc, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatalf("Get(%s): %v", key, err)
+ }
+ defer rc.Close()
+ content, err := io.ReadAll(rc)
+ if err != nil {
+ t.Fatalf("read Get(%s) body: %v", key, err)
+ }
+ return content
+ }
+
+ t.Run("GetMissing", func(t *testing.T) {
+ s := newStore(t)
+ _, err := s.Get(ctx, "repo/objects/ab/cdef")
+ if !errors.Is(err, ErrNotFound) {
+ t.Errorf("Get(missing) error = %v, want ErrNotFound", err)
+ }
+ })
+
+ t.Run("ExistsMissing", func(t *testing.T) {
+ s := newStore(t)
+ ok, err := s.Exists(ctx, "repo/objects/ab/cdef")
+ if err != nil {
+ t.Fatalf("Exists: %v", err)
+ }
+ if ok {
+ t.Error("Exists(missing) = true, want false")
+ }
+ })
+
+ t.Run("PutGetRoundtrip", func(t *testing.T) {
+ s := newStore(t)
+ // Git objects are binary: zlib streams with NUL and high bytes.
+ content := []byte("blob 12\x00\x00\x01\xff\xfebinary data")
+ put(t, s, "repo/objects/ab/cdef", content)
+
+ if got := get(t, s, "repo/objects/ab/cdef"); !bytes.Equal(got, content) {
+ t.Errorf("Get returned %q, want %q", got, content)
+ }
+ ok, err := s.Exists(ctx, "repo/objects/ab/cdef")
+ if err != nil {
+ t.Fatalf("Exists: %v", err)
+ }
+ if !ok {
+ t.Error("Exists(present) = false, want true")
+ }
+ })
+
+ t.Run("RePutIsNoOp", func(t *testing.T) {
+ s := newStore(t)
+ content := []byte("same content, same hash")
+ put(t, s, "repo/objects/ab/cdef", content)
+ put(t, s, "repo/objects/ab/cdef", content)
+
+ if got := get(t, s, "repo/objects/ab/cdef"); !bytes.Equal(got, content) {
+ t.Errorf("Get after re-Put returned %q, want %q", got, content)
+ }
+ keys, err := s.List(ctx, "repo/")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if want := []string{"repo/objects/ab/cdef"}; !slices.Equal(keys, want) {
+ t.Errorf("List after re-Put = %v, want %v", keys, want)
+ }
+ })
+
+ t.Run("ExistsIsNotPrefixMatch", func(t *testing.T) {
+ s := newStore(t)
+ put(t, s, "repo/objects/ab/cdef", []byte("x"))
+
+ ok, err := s.Exists(ctx, "repo/objects/ab")
+ if err != nil {
+ t.Fatalf("Exists: %v", err)
+ }
+ if ok {
+ t.Error(`Exists("repo/objects/ab") = true, want false — a key is not its prefix`)
+ }
+ })
+
+ t.Run("ListPrefixBoundary", func(t *testing.T) {
+ s := newStore(t)
+ for _, key := range []string{
+ "repo1/objects/pack/pack-1.pack",
+ "repo1/objects/aa/111",
+ "repo10/objects/bb/222",
+ } {
+ put(t, s, key, []byte(key))
+ }
+
+ // "repo1/" must not match "repo10/…": the slash is the boundary.
+ keys, err := s.List(ctx, "repo1/")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ want := []string{"repo1/objects/aa/111", "repo1/objects/pack/pack-1.pack"}
+ if !slices.Equal(keys, want) {
+ t.Errorf("List(repo1/) = %v, want %v (sorted)", keys, want)
+ }
+
+ keys, err = s.List(ctx, "repo1/objects/pack/")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if want := []string{"repo1/objects/pack/pack-1.pack"}; !slices.Equal(keys, want) {
+ t.Errorf("List(repo1/objects/pack/) = %v, want %v", keys, want)
+ }
+ })
+
+ t.Run("ListNoMatch", func(t *testing.T) {
+ s := newStore(t)
+ put(t, s, "repo/objects/ab/cdef", []byte("x"))
+
+ keys, err := s.List(ctx, "other/")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(keys) != 0 {
+ t.Errorf("List(other/) = %v, want empty", keys)
+ }
+ })
+}
+
+func TestMemConformance(t *testing.T) {
+ testConformance(t, func(t *testing.T) Store { return NewMem() })
+}
diff --git a/internal/store/mem.go b/internal/store/mem.go
new file mode 100644
index 0000000..d1763f3
--- /dev/null
+++ b/internal/store/mem.go
@@ -0,0 +1,69 @@
+package store
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "sort"
+ "strings"
+ "sync"
+)
+
+// Mem is an in-memory Store for tests. It must stay in lockstep with the S3
+// implementation — the conformance suite runs against both.
+type Mem struct {
+ mu sync.RWMutex
+ objects map[string][]byte
+}
+
+// NewMem returns an empty in-memory store.
+func NewMem() *Mem {
+ return &Mem{objects: make(map[string][]byte)}
+}
+
+var _ Store = (*Mem)(nil)
+
+// Put implements Store.
+func (m *Mem) Put(ctx context.Context, key string, r io.Reader) error {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return err
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.objects[key] = data
+ return nil
+}
+
+// Get implements Store.
+func (m *Mem) Get(ctx context.Context, key string) (io.ReadCloser, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ data, ok := m.objects[key]
+ if !ok {
+ return nil, ErrNotFound
+ }
+ return io.NopCloser(bytes.NewReader(data)), nil
+}
+
+// Exists implements Store.
+func (m *Mem) Exists(ctx context.Context, key string) (bool, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ _, ok := m.objects[key]
+ return ok, nil
+}
+
+// List implements Store.
+func (m *Mem) List(ctx context.Context, prefix string) ([]string, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ var keys []string
+ for k := range m.objects {
+ if strings.HasPrefix(k, prefix) {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ return keys, nil
+}
diff --git a/internal/store/s3.go b/internal/store/s3.go
new file mode 100644
index 0000000..77ce776
--- /dev/null
+++ b/internal/store/s3.go
@@ -0,0 +1,137 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
+)
+
+// S3 is the durable Store implementation backed by an S3-compatible bucket
+// (AWS S3, MinIO, …). Puts overwrite unconditionally — keys are
+// content-addressed, so rewriting the same content is a no-op.
+type S3 struct {
+ client *s3.Client
+ uploader *transfermanager.Client
+ bucket string
+ prefix string // optional key prefix inside the bucket; "" or ends with "/"
+}
+
+var _ Store = (*S3)(nil)
+
+// NewS3 returns a Store over the given bucket. prefix, if non-empty, is
+// prepended to every key (a "/" separator is added if missing) — it scopes
+// the store to a corner of a shared bucket.
+func NewS3(client *s3.Client, bucket, prefix string) *S3 {
+ if prefix != "" && !strings.HasSuffix(prefix, "/") {
+ prefix += "/"
+ }
+ return &S3{
+ client: client,
+ uploader: transfermanager.New(client),
+ bucket: bucket,
+ prefix: prefix,
+ }
+}
+
+// NewS3FromEnv builds an S3 store from the environment:
+//
+// - GITMOTE_S3_BUCKET — bucket name (required)
+// - GITMOTE_S3_ENDPOINT — custom endpoint, e.g. a local MinIO (optional;
+// implies path-style addressing)
+// - GITMOTE_S3_PREFIX — key prefix inside the bucket (optional)
+//
+// Region and credentials come from the standard AWS environment
+// (AWS_REGION, AWS_ACCESS_KEY_ID, …) and config files.
+func NewS3FromEnv(ctx context.Context) (*S3, error) {
+ bucket := os.Getenv("GITMOTE_S3_BUCKET")
+ if bucket == "" {
+ return nil, errors.New("store: GITMOTE_S3_BUCKET is not set")
+ }
+ cfg, err := config.LoadDefaultConfig(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("store: load aws config: %w", err)
+ }
+ client := s3.NewFromConfig(cfg, func(o *s3.Options) {
+ if endpoint := os.Getenv("GITMOTE_S3_ENDPOINT"); endpoint != "" {
+ o.BaseEndpoint = aws.String(endpoint)
+ // Virtual-host addressing needs DNS per bucket; local
+ // S3-compatible stores like MinIO want path-style.
+ o.UsePathStyle = true
+ }
+ })
+ return NewS3(client, bucket, os.Getenv("GITMOTE_S3_PREFIX")), nil
+}
+
+// Put implements Store. The uploader streams r, switching to multipart for
+// large bodies (packfiles), so r need not be seekable.
+func (s *S3) Put(ctx context.Context, key string, r io.Reader) error {
+ _, err := s.uploader.UploadObject(ctx, &transfermanager.UploadObjectInput{
+ Bucket: aws.String(s.bucket),
+ Key: aws.String(s.prefix + key),
+ Body: r,
+ })
+ if err != nil {
+ return fmt.Errorf("store: put %s: %w", key, err)
+ }
+ return nil
+}
+
+// Get implements Store.
+func (s *S3) Get(ctx context.Context, key string) (io.ReadCloser, error) {
+ out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
+ Bucket: aws.String(s.bucket),
+ Key: aws.String(s.prefix + key),
+ })
+ if err != nil {
+ var noKey *types.NoSuchKey
+ if errors.As(err, &noKey) {
+ return nil, ErrNotFound
+ }
+ return nil, fmt.Errorf("store: get %s: %w", key, err)
+ }
+ return out.Body, nil
+}
+
+// Exists implements Store.
+func (s *S3) Exists(ctx context.Context, key string) (bool, error) {
+ _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
+ Bucket: aws.String(s.bucket),
+ Key: aws.String(s.prefix + key),
+ })
+ if err != nil {
+ var notFound *types.NotFound
+ if errors.As(err, &notFound) {
+ return false, nil
+ }
+ return false, fmt.Errorf("store: exists %s: %w", key, err)
+ }
+ return true, nil
+}
+
+// List implements Store.
+func (s *S3) List(ctx context.Context, prefix string) ([]string, error) {
+ var keys []string
+ paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
+ Bucket: aws.String(s.bucket),
+ Prefix: aws.String(s.prefix + prefix),
+ })
+ for paginator.HasMorePages() {
+ page, err := paginator.NextPage(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("store: list %s: %w", prefix, err)
+ }
+ for _, obj := range page.Contents {
+ keys = append(keys, strings.TrimPrefix(aws.ToString(obj.Key), s.prefix))
+ }
+ }
+ return keys, nil
+}
diff --git a/internal/store/s3_test.go b/internal/store/s3_test.go
new file mode 100644
index 0000000..a4f2da1
--- /dev/null
+++ b/internal/store/s3_test.go
@@ -0,0 +1,59 @@
+package store
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+// TestS3Conformance runs the shared suite against a real S3-compatible
+// endpoint (MinIO in dev). Gated by env: skipped unless GITMOTE_TEST_S3 is
+// set; GITMOTE_S3_BUCKET, GITMOTE_S3_ENDPOINT, and the standard AWS_* vars
+// configure the target.
+func TestS3Conformance(t *testing.T) {
+ if os.Getenv("GITMOTE_TEST_S3") == "" {
+ t.Skip("set GITMOTE_TEST_S3=1 (plus GITMOTE_S3_BUCKET, GITMOTE_S3_ENDPOINT, AWS_*) to run against MinIO/S3")
+ }
+ testConformance(t, newTestS3)
+}
+
+// newTestS3 builds an S3 store scoped to a unique prefix so concurrent test
+// runs can share a bucket, and deletes everything under it on cleanup.
+func newTestS3(t *testing.T) Store {
+ t.Helper()
+ ctx := context.Background()
+
+ var suffix [8]byte
+ if _, err := rand.Read(suffix[:]); err != nil {
+ t.Fatalf("rand: %v", err)
+ }
+ t.Setenv("GITMOTE_S3_PREFIX", fmt.Sprintf("conformance/%s/%s", t.Name(), hex.EncodeToString(suffix[:])))
+
+ s, err := NewS3FromEnv(ctx)
+ if err != nil {
+ t.Fatalf("NewS3FromEnv: %v", err)
+ }
+ t.Cleanup(func() {
+ keys, err := s.List(ctx, "")
+ if err != nil {
+ t.Errorf("cleanup list: %v", err)
+ return
+ }
+ for _, key := range keys {
+ _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
+ Bucket: aws.String(s.bucket),
+ Key: aws.String(s.prefix + key),
+ })
+ if err != nil {
+ t.Errorf("cleanup delete %s: %v", key, err)
+ }
+ }
+ })
+ return s
+}
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..b3d8b51
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,35 @@
+// Package store provides durable, content-addressed storage for git objects
+// and packfiles, per the S3 layout in docs/architecture/storage.md. Keys are
+// content-addressed ({repo}/objects/… and {repo}/objects/pack/…), so values
+// are immutable: a re-Put of an existing key is a no-op. This idempotence is
+// the substrate for the content-before-pointer invariant in
+// docs/architecture/safety.md.
+package store
+
+import (
+ "context"
+ "errors"
+ "io"
+)
+
+// ErrNotFound is returned by Get for a key that does not exist.
+var ErrNotFound = errors.New("store: key not found")
+
+// Store is the storage contract for immutable git objects and packs.
+type Store interface {
+ // Put stores the content read from r under key. Keys are
+ // content-addressed, so a re-Put of an existing key is a no-op.
+ Put(ctx context.Context, key string, r io.Reader) error
+
+ // Get returns the content stored under key. The caller must close the
+ // returned reader. Returns ErrNotFound if the key does not exist.
+ Get(ctx context.Context, key string) (io.ReadCloser, error)
+
+ // Exists reports whether key exists.
+ Exists(ctx context.Context, key string) (bool, error)
+
+ // List returns, in lexicographic order, all keys that start with
+ // prefix — plain string-prefix matching, as in S3. Callers listing a
+ // "directory" must include the trailing slash.
+ List(ctx context.Context, prefix string) ([]string, error)
+}
diff --git a/tasks/02-s3-object-store.md b/tasks/02-s3-object-store.md
deleted file mode 100644
index e87dda3..0000000
--- a/tasks/02-s3-object-store.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# 02 — S3 object store
-
-Depends on: 01.
-
-## Spec
-
-Durable, content-addressed storage for git objects and packs in S3, per the S3
-layout in [storage.md](../docs/architecture/storage.md). PUT is idempotent
-(re-PUT of the same hash is a no-op) — the substrate for the content-before-pointer
-invariant in [safety.md](../docs/architecture/safety.md).
-
-## Current
-
-Nothing yet.
-
-## Change
-
-- `internal/store/store.go` — the `Store` interface (the storage contract):
- Put / Get / Exists / List over keys under `{repo}/objects/…` and
- `{repo}/objects/pack/…`.
-- `internal/store/s3.go` — S3 implementation (`aws-sdk-go-v2`; the arch notes
- the SDK directly, with `rclone` as a zero-code fallback). Config via env.
-- `internal/store/mem.go` — in-memory implementation for tests, kept in lockstep
- with the S3 impl (CONTRIBUTING: both satisfy `Store`).
-- A shared conformance test suite exercised against both impls.
-
-## Verify
-
-- Conformance suite passes for `mem` always, and for `s3` against MinIO when
- available (gated by env).
-- Re-PUT of the same key is idempotent; `Exists`/`List` behave at prefix
- boundaries.
-- Non-breaking: a library with no route wired in yet.
diff --git a/tasks/README.md b/tasks/README.md
index f718266..2fb0bb0 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,7 +6,6 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-- [02-s3-object-store.md](02-s3-object-store.md) — `Store` interface + S3 and in-memory impls for objects/packs.
- [03-metadata-s3lite.md](03-metadata-s3lite.md) — Embed s3lite; schema, migrations, query layer, ref CAS.
- [04-repo-materialization.md](04-repo-materialization.md) — Build ephemeral bare repos from refs + hydrated objects.
- [05-read-path-clone.md](05-read-path-clone.md) — Smart-HTTP `upload-pack`; `git clone` works.