aboutsummaryrefslogtreecommitdiff
path: root/ref/store/memory
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-05-24 13:41:34 +0000
committerGravatar Runxi Yu2026-05-24 14:12:35 +0000
commit947bf81a33c6e4e5d21c8b36f9317fe00b84f6ae (patch)
tree67824655ef9dbf2d941dae06d59ea29a1e32d458 /ref/store/memory
parentREADME: Update (diff)
signatureNo signature
ref/store/memory: Simple memory-backed ref store v0.1.175
Diffstat (limited to 'ref/store/memory')
-rw-r--r--ref/store/memory/batch.go195
-rw-r--r--ref/store/memory/doc.go2
-rw-r--r--ref/store/memory/read.go125
-rw-r--r--ref/store/memory/ref.go45
-rw-r--r--ref/store/memory/store.go42
-rw-r--r--ref/store/memory/store_test.go282
-rw-r--r--ref/store/memory/transaction.go97
-rw-r--r--ref/store/memory/update.go409
8 files changed, 1197 insertions, 0 deletions
diff --git a/ref/store/memory/batch.go b/ref/store/memory/batch.go
new file mode 100644
index 00000000..df3d554d
--- /dev/null
+++ b/ref/store/memory/batch.go
@@ -0,0 +1,195 @@
+package memory
+
+import (
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// Batch stages in-memory updates for one subset commit.
+type Batch struct {
+ store *Store
+ ops []queuedUpdate
+}
+
+var _ refstore.Batch = (*Batch)(nil)
+
+// BeginBatch creates one new in-memory batch.
+//
+//nolint:ireturn
+func (store *Store) BeginBatch() (refstore.Batch, error) {
+ return &Batch{
+ store: store,
+ ops: make([]queuedUpdate, 0, 8),
+ }, nil
+}
+
+// Create queues a detached reference creation.
+func (batch *Batch) Create(name string, newID objectid.ObjectID) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateCreate, newID: newID})
+}
+
+// Update queues a detached reference update.
+func (batch *Batch) Update(name string, newID, oldID objectid.ObjectID) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateReplace, newID: newID, oldID: oldID})
+}
+
+// Delete queues a detached reference deletion.
+func (batch *Batch) Delete(name string, oldID objectid.ObjectID) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateDelete, oldID: oldID})
+}
+
+// Verify queues a detached reference verification.
+func (batch *Batch) Verify(name string, oldID objectid.ObjectID) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateVerify, oldID: oldID})
+}
+
+// CreateSymbolic queues a symbolic reference creation.
+func (batch *Batch) CreateSymbolic(name, newTarget string) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateCreateSymbolic, newTarget: newTarget})
+}
+
+// UpdateSymbolic queues a symbolic reference update.
+func (batch *Batch) UpdateSymbolic(name, newTarget, oldTarget string) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateReplaceSymbolic, newTarget: newTarget, oldTarget: oldTarget})
+}
+
+// DeleteSymbolic queues a symbolic reference deletion.
+func (batch *Batch) DeleteSymbolic(name, oldTarget string) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateDeleteSymbolic, oldTarget: oldTarget})
+}
+
+// VerifySymbolic queues a symbolic reference verification.
+func (batch *Batch) VerifySymbolic(name, oldTarget string) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateVerifySymbolic, oldTarget: oldTarget})
+}
+
+// Apply validates queued operations,
+// drops rejected operations,
+// and applies the remaining compatible set.
+// Concurrent readers observe
+// either the pre-Apply state
+// or the post-Apply state.
+func (batch *Batch) Apply() ([]refstore.BatchResult, error) {
+ results := make([]refstore.BatchResult, len(batch.ops))
+ remainingIdx := make([]int, 0, len(batch.ops))
+ remainingOps := make([]queuedUpdate, 0, len(batch.ops))
+ seenTargets := make(map[string]struct{}, len(batch.ops))
+
+ batch.store.mu.Lock()
+ defer batch.store.mu.Unlock()
+
+ for i, op := range batch.ops {
+ results[i].Name = op.name
+
+ target, err := resolveQueuedUpdateTarget(batch.store.refs, op)
+ if err != nil {
+ if isBatchRejected(err) {
+ results[i].Status = refstore.BatchStatusRejected
+ results[i].Error = batchResultError(err)
+
+ continue
+ }
+
+ results[i].Status = refstore.BatchStatusFatal
+ results[i].Error = batchResultError(err)
+
+ for j := i + 1; j < len(results); j++ {
+ results[j].Name = batch.ops[j].name
+ results[j].Status = refstore.BatchStatusNotAttempted
+ results[j].Error = batchResultError(err)
+ }
+
+ return results, err
+ }
+
+ if _, exists := seenTargets[target.name]; exists {
+ results[i].Status = refstore.BatchStatusRejected
+ results[i].Error = &refstore.DuplicateUpdateError{}
+
+ continue
+ }
+
+ seenTargets[target.name] = struct{}{}
+
+ remainingIdx = append(remainingIdx, i)
+ remainingOps = append(remainingOps, op)
+ }
+
+ for len(remainingOps) > 0 {
+ prepared, err := prepareUpdates(batch.store.refs, remainingOps)
+ if err == nil {
+ next := cloneRefs(batch.store.refs)
+ applyPreparedUpdates(next, prepared)
+ batch.store.refs = next
+
+ for _, idx := range remainingIdx {
+ results[idx].Status = refstore.BatchStatusApplied
+ }
+
+ return results, nil
+ }
+
+ if !isBatchRejected(err) {
+ fatalName := batchResultName(err)
+ fatalMarked := false
+
+ for i, idx := range remainingIdx {
+ if !fatalMarked && remainingOps[i].name == fatalName && fatalName != "" {
+ results[idx].Status = refstore.BatchStatusFatal
+ results[idx].Error = batchResultError(err)
+ fatalMarked = true
+
+ continue
+ }
+
+ results[idx].Status = refstore.BatchStatusNotAttempted
+ results[idx].Error = batchResultError(err)
+ }
+
+ return results, err
+ }
+
+ name := batchResultName(err)
+ rejectedAt := -1
+
+ for i, op := range remainingOps {
+ if op.name == name {
+ rejectedAt = i
+
+ break
+ }
+ }
+
+ if rejectedAt < 0 {
+ for _, idx := range remainingIdx {
+ results[idx].Status = refstore.BatchStatusNotAttempted
+ results[idx].Error = batchResultError(err)
+ }
+
+ return results, err
+ }
+
+ results[remainingIdx[rejectedAt]].Status = refstore.BatchStatusRejected
+ results[remainingIdx[rejectedAt]].Error = batchResultError(err)
+ remainingIdx = append(remainingIdx[:rejectedAt], remainingIdx[rejectedAt+1:]...)
+ remainingOps = append(remainingOps[:rejectedAt], remainingOps[rejectedAt+1:]...)
+ }
+
+ return results, nil
+}
+
+// Abort abandons the batch.
+func (batch *Batch) Abort() error {
+ return nil
+}
+
+func (batch *Batch) queue(op queuedUpdate) error {
+ err := validateQueuedUpdate(op)
+ if err != nil {
+ return err
+ }
+
+ batch.ops = append(batch.ops, op)
+
+ return nil
+}
diff --git a/ref/store/memory/doc.go b/ref/store/memory/doc.go
new file mode 100644
index 00000000..3a2072a0
--- /dev/null
+++ b/ref/store/memory/doc.go
@@ -0,0 +1,2 @@
+// Package memory provides an in-memory reference store.
+package memory
diff --git a/ref/store/memory/read.go b/ref/store/memory/read.go
new file mode 100644
index 00000000..5f8095bb
--- /dev/null
+++ b/ref/store/memory/read.go
@@ -0,0 +1,125 @@
+package memory
+
+import (
+ "fmt"
+ "path"
+ "slices"
+ "strings"
+
+ "codeberg.org/lindenii/furgit/ref"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// Resolve resolves one reference name
+// from the in-memory namespace.
+func (store *Store) Resolve(name string) (ref.Ref, error) { //nolint:ireturn
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ return publicRef(name, store.refs[name])
+}
+
+// ResolveToDetached resolves symbolic references
+// through the in-memory namespace
+// until one detached reference is reached.
+func (store *Store) ResolveToDetached(name string) (ref.Detached, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ return store.resolveToDetachedLocked(name)
+}
+
+// List lists references from the in-memory namespace.
+func (store *Store) List(pattern string) ([]ref.Ref, error) {
+ matchAll := pattern == ""
+ if !matchAll {
+ _, err := path.Match(pattern, "HEAD")
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ names := make([]string, 0, len(store.refs))
+ for name := range store.refs {
+ if !matchAll {
+ matched, err := path.Match(pattern, name)
+ if err != nil {
+ return nil, err
+ }
+
+ if !matched {
+ continue
+ }
+ }
+
+ names = append(names, name)
+ }
+
+ slices.Sort(names)
+
+ refs := make([]ref.Ref, 0, len(names))
+ for _, name := range names {
+ resolved, err := publicRef(name, store.refs[name])
+ if err != nil {
+ return nil, err
+ }
+
+ refs = append(refs, resolved)
+ }
+
+ return refs, nil
+}
+
+func (store *Store) resolveToDetachedLocked(name string) (ref.Detached, error) {
+ cur := name
+ seen := make(map[string]struct{})
+
+ for {
+ if _, ok := seen[cur]; ok {
+ return ref.Detached{}, fmt.Errorf("refstore/memory: symbolic reference cycle at %q", cur)
+ }
+
+ seen[cur] = struct{}{}
+
+ resolved, err := publicRef(cur, store.refs[cur])
+ if err != nil {
+ return ref.Detached{}, err
+ }
+
+ switch resolved := resolved.(type) {
+ case ref.Detached:
+ return resolved, nil
+ case ref.Symbolic:
+ target := strings.TrimSpace(resolved.Target)
+ if target == "" {
+ return ref.Detached{}, fmt.Errorf("refstore/memory: symbolic reference %q has empty target", resolved.Name())
+ }
+
+ cur = target
+ default:
+ return ref.Detached{}, fmt.Errorf("refstore/memory: unsupported reference type %T", resolved)
+ }
+ }
+}
+
+func publicRef(name string, stored storedRef) (ref.Ref, error) { //nolint:ireturn
+ switch stored.kind {
+ case storedDetached:
+ detached := ref.Detached{RefName: name, ID: stored.id}
+ if stored.peeled != nil {
+ peeled := *stored.peeled
+ detached.Peeled = &peeled
+ }
+
+ return detached, nil
+ case storedSymbolic:
+ return ref.Symbolic{RefName: name, Target: stored.target}, nil
+ case storedMissing:
+ return nil, refstore.ErrReferenceNotFound
+ default:
+ return nil, fmt.Errorf("refstore/memory: unsupported stored reference kind %d", stored.kind)
+ }
+}
diff --git a/ref/store/memory/ref.go b/ref/store/memory/ref.go
new file mode 100644
index 00000000..67d9268f
--- /dev/null
+++ b/ref/store/memory/ref.go
@@ -0,0 +1,45 @@
+package memory
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// Because the public one includes the ref's name/identity.
+
+type storedKind uint8
+
+const (
+ storedMissing storedKind = iota
+ storedDetached
+ storedSymbolic
+)
+
+// Missing is obviously not the best design
+// but it does make it easier to operate on internally.
+// Might make a tagged union wrapper, though...
+// Or might just make a wrapper struct that has an "ok" bool.
+
+type storedRef struct {
+ kind storedKind
+ id objectid.ObjectID
+ target string
+ peeled *objectid.ObjectID
+}
+
+func cloneStoredRef(stored storedRef) storedRef {
+ if stored.peeled == nil {
+ return stored
+ }
+
+ peeled := *stored.peeled
+ stored.peeled = &peeled
+
+ return stored
+}
+
+func cloneRefs(refs map[string]storedRef) map[string]storedRef {
+ cloned := make(map[string]storedRef, len(refs))
+ for name, stored := range refs {
+ cloned[name] = cloneStoredRef(stored)
+ }
+
+ return cloned
+}
diff --git a/ref/store/memory/store.go b/ref/store/memory/store.go
new file mode 100644
index 00000000..d77f72e2
--- /dev/null
+++ b/ref/store/memory/store.go
@@ -0,0 +1,42 @@
+package memory
+
+import (
+ "sync"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// Store reads and writes one in-memory Git reference namespace.
+//
+// Labels: Close-Caller.
+type Store struct {
+ mu sync.RWMutex
+ algo objectid.Algorithm
+ refs map[string]storedRef
+}
+
+var (
+ _ refstore.Reader = (*Store)(nil)
+ _ refstore.Transactioner = (*Store)(nil)
+ _ refstore.Batcher = (*Store)(nil)
+)
+
+// New builds one empty in-memory reference store for one object format.
+func New(algo objectid.Algorithm) (*Store, error) {
+ if algo.Size() == 0 {
+ return nil, objectid.ErrInvalidAlgorithm
+ }
+
+ return &Store{
+ algo: algo,
+ refs: make(map[string]storedRef),
+ }, nil
+}
+
+// Close closes the in-memory reference store.
+//
+// Labels: MT-Unsafe.
+func (store *Store) Close() error {
+ return nil
+}
diff --git a/ref/store/memory/store_test.go b/ref/store/memory/store_test.go
new file mode 100644
index 00000000..d8735805
--- /dev/null
+++ b/ref/store/memory/store_test.go
@@ -0,0 +1,282 @@
+package memory_test
+
+import (
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+ "codeberg.org/lindenii/furgit/ref/store/memory"
+)
+
+// Unlike the public ResolveToDetached,
+// this one does not resolve symbolic refs.
+func resolveDetached(t *testing.T, store *memory.Store, name string) ref.Detached {
+ t.Helper()
+
+ resolved, err := store.Resolve(name)
+ if err != nil {
+ t.Fatalf("Resolve(%q): %v", name, err)
+ }
+
+ detached, ok := resolved.(ref.Detached)
+ if !ok {
+ t.Fatalf("Resolve(%q) = %T, want ref.Detached", name, resolved)
+ }
+
+ return detached
+}
+
+func TestReadListAndResolveSymbolic(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ store, err := memory.New(algo)
+ if err != nil {
+ t.Fatalf("memory.New: %v", err)
+ }
+
+ mainID := algo.Sum([]byte("main"))
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Create("refs/heads/main", mainID)
+ if err != nil {
+ t.Fatalf("Create(main): %v", err)
+ }
+
+ err = tx.CreateSymbolic("HEAD", "refs/heads/main")
+ if err != nil {
+ t.Fatalf("CreateSymbolic(HEAD): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit seed refs: %v", err)
+ }
+
+ head, err := store.Resolve("HEAD")
+ if err != nil {
+ t.Fatalf("Resolve(HEAD): %v", err)
+ }
+
+ symbolic, ok := head.(ref.Symbolic)
+ if !ok {
+ t.Fatalf("Resolve(HEAD) = %T, want ref.Symbolic", head)
+ }
+
+ if symbolic.Target != "refs/heads/main" {
+ t.Fatalf("HEAD target = %q, want refs/heads/main", symbolic.Target)
+ }
+
+ detached, err := store.ResolveToDetached("HEAD")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(HEAD): %v", err)
+ }
+
+ if detached.ID != mainID {
+ t.Fatalf("ResolveToDetached(HEAD) ID = %v, want %v", detached.ID, mainID)
+ }
+
+ listed, err := store.List("")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+
+ if len(listed) != 2 || listed[0].Name() != "HEAD" || listed[1].Name() != "refs/heads/main" {
+ t.Fatalf("List names = %v, want [HEAD refs/heads/main]", listed)
+ }
+ })
+}
+
+func TestTransactionRejectLeavesStoreUnchanged(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ store, err := memory.New(algo)
+ if err != nil {
+ t.Fatalf("memory.New: %v", err)
+ }
+
+ mainID := algo.Sum([]byte("main"))
+ devID := algo.Sum([]byte("dev"))
+ nextID := algo.Sum([]byte("next"))
+ wrongOld := algo.Sum([]byte("wrong"))
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Create("refs/heads/main", mainID)
+ if err != nil {
+ t.Fatalf("Create(main): %v", err)
+ }
+
+ err = tx.Create("refs/heads/dev", devID)
+ if err != nil {
+ t.Fatalf("Create(dev): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit seed refs: %v", err)
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Update("refs/heads/main", nextID, mainID)
+ if err != nil {
+ t.Fatalf("Update(main): %v", err)
+ }
+
+ err = tx.Update("refs/heads/dev", nextID, wrongOld)
+ if err != nil {
+ t.Fatalf("Update(dev): %v", err)
+ }
+
+ err = tx.Commit()
+ if err == nil {
+ t.Fatalf("Commit succeeded, want incorrect old value error")
+ }
+
+ var oldValueErr *refstore.IncorrectOldValueError
+ if !errors.As(err, &oldValueErr) {
+ t.Fatalf("Commit error = %T %v, want IncorrectOldValueError", err, err)
+ }
+
+ if got := resolveDetached(t, store, "refs/heads/main").ID; got != mainID {
+ t.Fatalf("main after rejected transaction = %v, want %v", got, mainID)
+ }
+
+ if got := resolveDetached(t, store, "refs/heads/dev").ID; got != devID {
+ t.Fatalf("dev after rejected transaction = %v, want %v", got, devID)
+ }
+ })
+}
+
+func TestBatchRejectsDuplicateResolvedTargetAndAppliesRemainder(t *testing.T) {
+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {
+ t.Parallel()
+
+ store, err := memory.New(algo)
+ if err != nil {
+ t.Fatalf("memory.New: %v", err)
+ }
+
+ mainID := algo.Sum([]byte("main"))
+ devID := algo.Sum([]byte("dev"))
+ nextMainID := algo.Sum([]byte("next-main"))
+ nextDevID := algo.Sum([]byte("next-dev"))
+ aliasID := algo.Sum([]byte("alias"))
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Create("refs/heads/main", mainID)
+ if err != nil {
+ t.Fatalf("Create(main): %v", err)
+ }
+
+ err = tx.Create("refs/heads/dev", devID)
+ if err != nil {
+ t.Fatalf("Create(dev): %v", err)
+ }
+
+ err = tx.CreateSymbolic("refs/heads/alias", "refs/heads/main")
+ if err != nil {
+ t.Fatalf("CreateSymbolic(alias): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit seed refs: %v", err)
+ }
+
+ batch, err := store.BeginBatch()
+ if err != nil {
+ t.Fatalf("BeginBatch: %v", err)
+ }
+
+ err = batch.Update("refs/heads/main", nextMainID, mainID)
+ if err != nil {
+ t.Fatalf("Update(main): %v", err)
+ }
+
+ err = batch.Update("refs/heads/alias", aliasID, mainID)
+ if err != nil {
+ t.Fatalf("Update(alias): %v", err)
+ }
+
+ err = batch.Update("refs/heads/dev", nextDevID, devID)
+ if err != nil {
+ t.Fatalf("Update(dev): %v", err)
+ }
+
+ results, err := batch.Apply()
+ if err != nil {
+ t.Fatalf("Apply: %v", err)
+ }
+
+ if len(results) != 3 {
+ t.Fatalf("len(results) = %d, want 3", len(results))
+ }
+
+ if results[0].Status != refstore.BatchStatusApplied {
+ t.Fatalf("results[0].Status = %v, want applied", results[0].Status)
+ }
+
+ if results[1].Status != refstore.BatchStatusRejected {
+ t.Fatalf("results[1].Status = %v, want rejected", results[1].Status)
+ }
+
+ var duplicateErr *refstore.DuplicateUpdateError
+ if !errors.As(results[1].Error, &duplicateErr) {
+ t.Fatalf("results[1].Error = %T %v, want DuplicateUpdateError", results[1].Error, results[1].Error)
+ }
+
+ if results[2].Status != refstore.BatchStatusApplied {
+ t.Fatalf("results[2].Status = %v, want applied", results[2].Status)
+ }
+
+ if got := resolveDetached(t, store, "refs/heads/main").ID; got != nextMainID {
+ t.Fatalf("main after batch = %v, want %v", got, nextMainID)
+ }
+
+ if got := resolveDetached(t, store, "refs/heads/dev").ID; got != nextDevID {
+ t.Fatalf("dev after batch = %v, want %v", got, nextDevID)
+ }
+
+ resolved, err := store.Resolve("refs/heads/alias")
+ if err != nil {
+ t.Fatalf("Resolve(alias): %v", err)
+ }
+
+ symbolic, ok := resolved.(ref.Symbolic)
+ if !ok {
+ t.Fatalf("Resolve(alias) = %T, want ref.Symbolic", resolved)
+ }
+
+ if symbolic.Target != "refs/heads/main" {
+ t.Fatalf("alias target = %q, want refs/heads/main", symbolic.Target)
+ }
+ })
+}
diff --git a/ref/store/memory/transaction.go b/ref/store/memory/transaction.go
new file mode 100644
index 00000000..81ae01ef
--- /dev/null
+++ b/ref/store/memory/transaction.go
@@ -0,0 +1,97 @@
+package memory
+
+import (
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// Transaction stages in-memory updates for one atomic commit.
+type Transaction struct {
+ store *Store
+ ops []queuedUpdate
+}
+
+var _ refstore.Transaction = (*Transaction)(nil)
+
+// BeginTransaction creates one new in-memory transaction.
+//
+//nolint:ireturn
+func (store *Store) BeginTransaction() (refstore.Transaction, error) {
+ return &Transaction{
+ store: store,
+ ops: make([]queuedUpdate, 0, 8),
+ }, nil
+}
+
+// Create queues a detached reference creation.
+func (tx *Transaction) Create(name string, newID objectid.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateCreate, newID: newID})
+}
+
+// Update queues a detached reference update.
+func (tx *Transaction) Update(name string, newID, oldID objectid.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateReplace, newID: newID, oldID: oldID})
+}
+
+// Delete queues a detached reference deletion.
+func (tx *Transaction) Delete(name string, oldID objectid.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateDelete, oldID: oldID})
+}
+
+// Verify queues a detached reference verification.
+func (tx *Transaction) Verify(name string, oldID objectid.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateVerify, oldID: oldID})
+}
+
+// CreateSymbolic queues a symbolic reference creation.
+func (tx *Transaction) CreateSymbolic(name, newTarget string) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateCreateSymbolic, newTarget: newTarget})
+}
+
+// UpdateSymbolic queues a symbolic reference update.
+func (tx *Transaction) UpdateSymbolic(name, newTarget, oldTarget string) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateReplaceSymbolic, newTarget: newTarget, oldTarget: oldTarget})
+}
+
+// DeleteSymbolic queues a symbolic reference deletion.
+func (tx *Transaction) DeleteSymbolic(name, oldTarget string) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateDeleteSymbolic, oldTarget: oldTarget})
+}
+
+// VerifySymbolic queues a symbolic reference verification.
+func (tx *Transaction) VerifySymbolic(name, oldTarget string) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateVerifySymbolic, oldTarget: oldTarget})
+}
+
+// Commit validates and applies the queued updates atomically.
+func (tx *Transaction) Commit() error {
+ tx.store.mu.Lock()
+ defer tx.store.mu.Unlock()
+
+ prepared, err := prepareUpdates(tx.store.refs, tx.ops)
+ if err != nil {
+ return err
+ }
+
+ next := cloneRefs(tx.store.refs)
+ applyPreparedUpdates(next, prepared)
+ tx.store.refs = next
+
+ return nil
+}
+
+// Abort abandons the transaction.
+func (tx *Transaction) Abort() error {
+ return nil
+}
+
+func (tx *Transaction) queue(op queuedUpdate) error {
+ err := validateQueuedUpdate(op)
+ if err != nil {
+ return err
+ }
+
+ tx.ops = append(tx.ops, op)
+
+ return nil
+}
diff --git a/ref/store/memory/update.go b/ref/store/memory/update.go
new file mode 100644
index 00000000..78a7ca2d
--- /dev/null
+++ b/ref/store/memory/update.go
@@ -0,0 +1,409 @@
+package memory
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ refname "codeberg.org/lindenii/furgit/ref/name"
+ refstore "codeberg.org/lindenii/furgit/ref/store"
+)
+
+type updateKind uint8
+
+const (
+ updateCreate updateKind = iota
+ updateReplace
+ updateDelete
+ updateVerify
+ updateCreateSymbolic
+ updateReplaceSymbolic
+ updateDeleteSymbolic
+ updateVerifySymbolic
+)
+
+type queuedUpdate struct {
+ name string
+ kind updateKind
+ newID objectid.ObjectID
+ oldID objectid.ObjectID
+ newTarget string
+ oldTarget string
+}
+
+type resolvedUpdateTarget struct {
+ name string
+ ref storedRef
+}
+
+type preparedUpdate struct {
+ op queuedUpdate
+ target resolvedUpdateTarget
+}
+
+type updateContextError struct {
+ name string
+ err error
+}
+
+func (err *updateContextError) Error() string {
+ return fmt.Sprintf("refstore/memory: update %q: %v", err.name, err.err)
+}
+
+func (err *updateContextError) Unwrap() error {
+ if err == nil {
+ return nil
+ }
+
+ return err.err
+}
+
+func wrapUpdateError(name string, err error) error {
+ if err == nil || name == "" {
+ return err
+ }
+
+ return &updateContextError{name: name, err: err}
+}
+
+func validateQueuedUpdate(op queuedUpdate) error {
+ if op.name == "" {
+ return wrapUpdateError(op.name, &refstore.InvalidNameError{Err: fmt.Errorf("empty reference name")})
+ }
+
+ switch op.kind {
+ case updateCreate, updateReplace:
+ err := refname.ValidateUpdateName(op.name, true)
+ if err != nil {
+ return wrapUpdateError(op.name, &refstore.InvalidNameError{Err: err})
+ }
+
+ if op.newID.Algorithm().Size() == 0 {
+ return wrapUpdateError(op.name, &refstore.InvalidValueError{Err: objectid.ErrInvalidAlgorithm})
+ }
+ case updateDelete, updateVerify:
+ err := refname.ValidateUpdateName(op.name, false)
+ if err != nil {
+ return wrapUpdateError(op.name, &refstore.InvalidNameError{Err: err})
+ }
+
+ if op.oldID.Algorithm().Size() == 0 {
+ return wrapUpdateError(op.name, &refstore.InvalidValueError{Err: objectid.ErrInvalidAlgorithm})
+ }
+ case updateCreateSymbolic, updateReplaceSymbolic:
+ err := refname.ValidateUpdateName(op.name, true)
+ if err != nil {
+ return wrapUpdateError(op.name, &refstore.InvalidNameError{Err: err})
+ }
+
+ if strings.TrimSpace(op.newTarget) == "" {
+ return wrapUpdateError(op.name, &refstore.InvalidValueError{Err: fmt.Errorf("empty symbolic target")})
+ }
+
+ err = refname.ValidateSymbolicTarget(op.name, strings.TrimSpace(op.newTarget))
+ if err != nil {
+ return wrapUpdateError(op.name, &refstore.InvalidValueError{Err: err})
+ }
+ case updateDeleteSymbolic, updateVerifySymbolic:
+ err := refname.ValidateUpdateName(op.name, false)
+ if err != nil {
+ return wrapUpdateError(op.name, &refstore.InvalidNameError{Err: err})
+ }
+ default:
+ return fmt.Errorf("refstore/memory: unsupported update operation %d", op.kind)
+ }
+
+ if op.kind == updateReplaceSymbolic || op.kind == updateDeleteSymbolic || op.kind == updateVerifySymbolic {
+ if strings.TrimSpace(op.oldTarget) == "" {
+ return wrapUpdateError(op.name, &refstore.InvalidValueError{Err: fmt.Errorf("empty symbolic old target")})
+ }
+ }
+
+ return nil
+}
+
+func prepareUpdates(refs map[string]storedRef, ops []queuedUpdate) ([]preparedUpdate, error) {
+ prepared, err := resolvePreparedUpdates(refs, ops)
+ if err != nil {
+ return prepared, err
+ }
+
+ deleted, written := collectPreparedWrites(prepared)
+ existing := collectVisibleNames(refs)
+
+ for _, name := range written {
+ err = verifyRefnameAvailable(name, existing, written, deleted)
+ if err != nil {
+ return prepared, err
+ }
+ }
+
+ err = verifyPreparedUpdates(refs, prepared)
+ if err != nil {
+ return prepared, err
+ }
+
+ return prepared, nil
+}
+
+func resolvePreparedUpdates(refs map[string]storedRef, ops []queuedUpdate) ([]preparedUpdate, error) {
+ prepared := make([]preparedUpdate, 0, len(ops))
+ targets := make(map[string]struct{}, len(ops))
+
+ for _, op := range ops {
+ target, err := resolveQueuedUpdateTarget(refs, op)
+ if err != nil {
+ return prepared, err
+ }
+
+ if _, exists := targets[target.name]; exists {
+ return prepared, wrapUpdateError(op.name, &refstore.DuplicateUpdateError{})
+ }
+
+ targets[target.name] = struct{}{}
+ prepared = append(prepared, preparedUpdate{op: op, target: target})
+ }
+
+ return prepared, nil
+}
+
+func resolveQueuedUpdateTarget(refs map[string]storedRef, op queuedUpdate) (resolvedUpdateTarget, error) {
+ switch op.kind {
+ case updateCreate:
+ return resolveOrdinaryTarget(refs, op.name, true)
+ case updateReplace, updateDelete, updateVerify:
+ return resolveOrdinaryTarget(refs, op.name, false)
+ case updateCreateSymbolic, updateReplaceSymbolic, updateDeleteSymbolic, updateVerifySymbolic:
+ return resolvedUpdateTarget{name: op.name, ref: directRead(refs, op.name)}, nil
+ default:
+ return resolvedUpdateTarget{}, fmt.Errorf("refstore/memory: unsupported update operation %d", op.kind)
+ }
+}
+
+func resolveOrdinaryTarget(refs map[string]storedRef, name string, allowMissing bool) (resolvedUpdateTarget, error) {
+ cur := name
+ seen := make(map[string]struct{})
+
+ for {
+ if _, ok := seen[cur]; ok {
+ return resolvedUpdateTarget{}, fmt.Errorf("refstore/memory: symbolic reference cycle at %q", cur)
+ }
+
+ seen[cur] = struct{}{}
+
+ refState := directRead(refs, cur)
+ switch refState.kind {
+ case storedMissing:
+ if !allowMissing {
+ return resolvedUpdateTarget{}, wrapUpdateError(name, refstore.ErrReferenceNotFound)
+ }
+
+ return resolvedUpdateTarget{name: cur, ref: refState}, nil
+ case storedDetached:
+ return resolvedUpdateTarget{name: cur, ref: refState}, nil
+ case storedSymbolic:
+ target := strings.TrimSpace(refState.target)
+ if target == "" {
+ return resolvedUpdateTarget{}, wrapUpdateError(name, &refstore.InvalidValueError{
+ Err: fmt.Errorf("symbolic reference has empty target"),
+ })
+ }
+
+ cur = target
+ default:
+ return resolvedUpdateTarget{}, fmt.Errorf("refstore/memory: unsupported stored reference kind %d", refState.kind)
+ }
+ }
+}
+
+func directRead(refs map[string]storedRef, name string) storedRef {
+ stored, ok := refs[name]
+ if !ok {
+ return storedRef{kind: storedMissing}
+ }
+
+ return cloneStoredRef(stored)
+}
+
+func collectPreparedWrites(prepared []preparedUpdate) (deleted map[string]struct{}, written []string) {
+ deleted = make(map[string]struct{})
+ written = make([]string, 0, len(prepared))
+
+ for _, item := range prepared {
+ switch item.op.kind {
+ case updateDelete, updateDeleteSymbolic:
+ deleted[item.target.name] = struct{}{}
+ case updateCreate, updateReplace, updateCreateSymbolic, updateReplaceSymbolic:
+ written = append(written, item.target.name)
+ case updateVerify, updateVerifySymbolic:
+ }
+ }
+
+ return deleted, written
+}
+
+func collectVisibleNames(refs map[string]storedRef) map[string]struct{} {
+ names := make(map[string]struct{}, len(refs))
+ for name := range refs {
+ names[name] = struct{}{}
+ }
+
+ return names
+}
+
+func verifyRefnameAvailable(name string, existing map[string]struct{}, writes []string, deleted map[string]struct{}) error {
+ for existingName := range existing {
+ if existingName == name {
+ continue
+ }
+
+ if _, skip := deleted[existingName]; skip {
+ continue
+ }
+
+ if refnamesConflict(name, existingName) {
+ return wrapUpdateError(name, &refstore.NameConflictError{Other: existingName})
+ }
+ }
+
+ for _, other := range writes {
+ if other == name {
+ continue
+ }
+
+ if refnamesConflict(name, other) {
+ return wrapUpdateError(name, &refstore.NameConflictError{Other: other})
+ }
+ }
+
+ return nil
+}
+
+func refnamesConflict(left, right string) bool {
+ return left == right ||
+ strings.HasPrefix(left, right+"/") ||
+ strings.HasPrefix(right, left+"/")
+}
+
+func verifyPreparedUpdates(refs map[string]storedRef, prepared []preparedUpdate) error {
+ for i := range prepared {
+ item := &prepared[i]
+ item.target.ref = directRead(refs, item.target.name)
+
+ err := verifyPreparedUpdateCurrent(*item)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func verifyPreparedUpdateCurrent(item preparedUpdate) error {
+ switch item.op.kind {
+ case updateCreate:
+ if item.target.ref.kind != storedMissing {
+ return wrapUpdateError(item.op.name, &refstore.CreateExistsError{})
+ }
+
+ return nil
+ case updateReplace, updateDelete, updateVerify:
+ if item.target.ref.kind == storedMissing {
+ return wrapUpdateError(item.op.name, refstore.ErrReferenceNotFound)
+ }
+
+ if item.target.ref.kind != storedDetached {
+ return wrapUpdateError(item.op.name, &refstore.ExpectedDetachedError{})
+ }
+
+ if item.target.ref.id != item.op.oldID {
+ return wrapUpdateError(item.op.name, &refstore.IncorrectOldValueError{
+ Actual: item.target.ref.id.String(),
+ Expected: item.op.oldID.String(),
+ })
+ }
+
+ return nil
+ case updateCreateSymbolic:
+ if item.target.ref.kind != storedMissing {
+ return wrapUpdateError(item.op.name, &refstore.CreateExistsError{})
+ }
+
+ return nil
+ case updateReplaceSymbolic, updateDeleteSymbolic, updateVerifySymbolic:
+ if item.target.ref.kind == storedMissing {
+ return wrapUpdateError(item.op.name, refstore.ErrReferenceNotFound)
+ }
+
+ if item.target.ref.kind != storedSymbolic {
+ return wrapUpdateError(item.op.name, &refstore.ExpectedSymbolicError{})
+ }
+
+ if strings.TrimSpace(item.target.ref.target) != strings.TrimSpace(item.op.oldTarget) {
+ return wrapUpdateError(item.op.name, &refstore.IncorrectOldValueError{
+ Actual: strings.TrimSpace(item.target.ref.target),
+ Expected: strings.TrimSpace(item.op.oldTarget),
+ })
+ }
+
+ return nil
+ }
+
+ return nil
+}
+
+func applyPreparedUpdates(refs map[string]storedRef, prepared []preparedUpdate) {
+ for _, item := range prepared {
+ switch item.op.kind {
+ case updateCreate, updateReplace:
+ refs[item.target.name] = storedRef{kind: storedDetached, id: item.op.newID}
+ case updateCreateSymbolic, updateReplaceSymbolic:
+ refs[item.target.name] = storedRef{kind: storedSymbolic, target: strings.TrimSpace(item.op.newTarget)}
+ case updateDelete, updateDeleteSymbolic:
+ delete(refs, item.target.name)
+ case updateVerify, updateVerifySymbolic:
+ }
+ }
+}
+
+func isBatchRejected(err error) bool {
+ _, invalidName := errors.AsType[*refstore.InvalidNameError](err)
+ _, invalidValue := errors.AsType[*refstore.InvalidValueError](err)
+ _, duplicateUpdate := errors.AsType[*refstore.DuplicateUpdateError](err)
+ _, createExists := errors.AsType[*refstore.CreateExistsError](err)
+ _, incorrectOldValue := errors.AsType[*refstore.IncorrectOldValueError](err)
+ _, expectedDetached := errors.AsType[*refstore.ExpectedDetachedError](err)
+ _, expectedSymbolic := errors.AsType[*refstore.ExpectedSymbolicError](err)
+ _, nameConflict := errors.AsType[*refstore.NameConflictError](err)
+
+ return errors.Is(err, refstore.ErrReferenceNotFound) ||
+ invalidName ||
+ invalidValue ||
+ duplicateUpdate ||
+ createExists ||
+ incorrectOldValue ||
+ expectedDetached ||
+ expectedSymbolic ||
+ nameConflict
+}
+
+func batchResultError(err error) error {
+ updateErr, ok := errors.AsType[*updateContextError](err)
+ if ok {
+ return updateErr.err
+ }
+
+ return err
+}
+
+func batchResultName(err error) string {
+ updateErr, ok := errors.AsType[*updateContextError](err)
+ if !ok {
+ return ""
+ }
+
+ return updateErr.name
+}
+
+// TODO: these really shouldn't be used in the batched path, really...