aboutsummaryrefslogtreecommitdiff
path: root/ref
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-06-24 14:52:28 +0000
committerGravatar Runxi Yu2026-06-24 14:53:00 +0000
commitdb5e5eb40d7f8652383099a2154f00158aa476c2 (patch)
treee8c60130234137fc59505a360e804917f6c6f01e /ref
parentTODO: maint, gc (diff)
ref/store{,/memory}: Add
Diffstat (limited to 'ref')
-rw-r--r--ref/store/batch.go84
-rw-r--r--ref/store/batch_store.go9
-rw-r--r--ref/store/doc.go18
-rw-r--r--ref/store/errors.go77
-rw-r--r--ref/store/memory/batch.go206
-rw-r--r--ref/store/memory/batch_test.go115
-rw-r--r--ref/store/memory/doc.go2
-rw-r--r--ref/store/memory/helpers_test.go46
-rw-r--r--ref/store/memory/memory.go43
-rw-r--r--ref/store/memory/read.go78
-rw-r--r--ref/store/memory/read_test.go80
-rw-r--r--ref/store/memory/ref.go43
-rw-r--r--ref/store/memory/transaction.go95
-rw-r--r--ref/store/memory/transaction_test.go96
-rw-r--r--ref/store/memory/update.go372
-rw-r--r--ref/store/reading.go34
-rw-r--r--ref/store/transaction.go57
-rw-r--r--ref/store/transactional_store.go13
18 files changed, 1468 insertions, 0 deletions
diff --git a/ref/store/batch.go b/ref/store/batch.go
new file mode 100644
index 00000000..dbe4c65b
--- /dev/null
+++ b/ref/store/batch.go
@@ -0,0 +1,84 @@
+package store
+
+import "lindenii.org/go/furgit/object/id"
+
+// Batch stages reference operations for one non-atomic apply.
+//
+// Unlike Transaction,
+// Batch may reject some queued operations
+// while still applying others successfully when Apply runs.
+//
+// Labels: MT-Unsafe.
+type Batch interface {
+ // Create creates one direct reference,
+ // requiring that the logical reference does not already exist.
+ Create(name string, newID id.ObjectID) error
+
+ // Update updates one direct reference,
+ // requiring that the current logical reference value matches oldID.
+ Update(name string, newID, oldID id.ObjectID) error
+
+ // Delete deletes one direct reference,
+ // requiring that the current logical reference value matches oldID.
+ Delete(name string, oldID id.ObjectID) error
+
+ // Verify verifies that the current logical reference value matches oldID.
+ Verify(name string, oldID id.ObjectID) error
+
+ // CreateSymbolic creates one symbolic reference,
+ // requiring that the named reference does not already exist.
+ CreateSymbolic(name, newTarget string) error
+
+ // UpdateSymbolic updates one symbolic reference directly,
+ // requiring that its current target matches oldTarget.
+ UpdateSymbolic(name, newTarget, oldTarget string) error
+
+ // DeleteSymbolic deletes one symbolic reference directly,
+ // requiring that its current target matches oldTarget.
+ DeleteSymbolic(name, oldTarget string) error
+
+ // VerifySymbolic verifies that the named symbolic reference
+ // currently points at oldTarget.
+ VerifySymbolic(name, oldTarget string) error
+
+ // Apply validates and applies queued operations,
+ // returning one result per queued operation in order.
+ // Fatal backend failures are returned separately.
+ //
+ // Malformed operations are rejected by the queueing methods above
+ // and do not enter the batch.
+ //
+ // Apply invalidates the receiver.
+ Apply() ([]BatchResult, error)
+
+ // Abort abandons the batch and releases any resources it holds.
+ //
+ // Abort invalidates the receiver.
+ Abort() error
+}
+
+// BatchStatus reports the outcome for one queued batch operation.
+type BatchStatus uint8
+
+const (
+ // BatchStatusApplied indicates that the operation was applied.
+ BatchStatusApplied BatchStatus = iota
+
+ // BatchStatusRejected indicates that the operation was rejected
+ // without aborting the rest of the batch.
+ BatchStatusRejected
+
+ // BatchStatusFatal indicates that the operation triggered a fatal failure.
+ BatchStatusFatal
+
+ // BatchStatusNotAttempted indicates that the operation was not attempted
+ // because an earlier operation failed fatally.
+ BatchStatusNotAttempted
+)
+
+// BatchResult reports the outcome for one queued batch operation.
+type BatchResult struct {
+ Name string
+ Status BatchStatus
+ Error error //exhaustruct:optional
+}
diff --git a/ref/store/batch_store.go b/ref/store/batch_store.go
new file mode 100644
index 00000000..16ca3d92
--- /dev/null
+++ b/ref/store/batch_store.go
@@ -0,0 +1,9 @@
+package store
+
+// Batcher begins non-atomic reference batches.
+type Batcher interface {
+ // BeginBatch creates one new queued batch.
+ //
+ // Labels: Deps-Borrowed, Life-Parent.
+ BeginBatch() (Batch, error)
+}
diff --git a/ref/store/doc.go b/ref/store/doc.go
new file mode 100644
index 00000000..d4fed3b1
--- /dev/null
+++ b/ref/store/doc.go
@@ -0,0 +1,18 @@
+// Package store provides interfaces for reference storage backends.
+//
+// Reference stores work directly with reference values,
+// [ref.Direct] and [ref.Symbolic].
+// Unlike object storage,
+// they have no separate fetch layer
+// to parse backend results into higher-level forms.
+//
+// The package separates read-only access
+// from atomic transactions and non-atomic batches.
+// Not every readable reference backend is writable,
+// and not every writable backend offers the same update model.
+//
+// Concrete implementations generally inherit the contract
+// documented by the interfaces they satisfy.
+// Implementation docs focus on additional guarantees
+// and implementation-specific behavior.
+package store
diff --git a/ref/store/errors.go b/ref/store/errors.go
new file mode 100644
index 00000000..64666ee6
--- /dev/null
+++ b/ref/store/errors.go
@@ -0,0 +1,77 @@
+package store
+
+import (
+ "errors"
+ "fmt"
+
+ "lindenii.org/go/furgit/object/id"
+)
+
+// ErrReferenceNotFound indicates that a reference does not exist in a backend.
+var ErrReferenceNotFound = errors.New("ref/store: reference not found")
+
+// ErrCreateExists indicates that a create operation
+// targeted an already-existing reference.
+var ErrCreateExists = errors.New("ref/store: reference already exists")
+
+// ErrDuplicateUpdate indicates that one transaction or batch
+// includes a duplicate resolved update target.
+var ErrDuplicateUpdate = errors.New("ref/store: duplicate reference update")
+
+// ErrExpectedDirect indicates that an operation required a direct reference
+// but found a different kind.
+var ErrExpectedDirect = errors.New("ref/store: expected direct reference")
+
+// ErrExpectedSymbolic indicates that an operation required a symbolic reference
+// but found a different kind.
+var ErrExpectedSymbolic = errors.New("ref/store: expected symbolic reference")
+
+// ErrInvalidValue indicates that a requested reference value is invalid,
+// such as an empty symbolic target
+// or an object ID whose format does not match the store.
+var ErrInvalidValue = errors.New("ref/store: invalid reference value")
+
+// ErrSymbolicCycle indicates that resolving a symbolic reference
+// encountered a cycle.
+var ErrSymbolicCycle = errors.New("ref/store: symbolic reference cycle")
+
+// NameConflictError indicates that one reference name conflicts with another
+// visible or queued reference name.
+type NameConflictError struct {
+ Other string
+}
+
+// Error implements error.
+func (err *NameConflictError) Error() string {
+ return fmt.Sprintf("ref/store: reference name conflict with %q", err.Other)
+}
+
+// WrongOldIDError indicates that a direct operation's expected old object ID
+// did not match the current reference value.
+type WrongOldIDError struct {
+ Actual id.ObjectID
+ Expected id.ObjectID
+}
+
+// Error implements error.
+func (err *WrongOldIDError) Error() string {
+ return fmt.Sprintf(
+ "ref/store: incorrect old object id: got %s, expected %s",
+ err.Actual, err.Expected,
+ )
+}
+
+// WrongOldTargetError indicates that a symbolic operation's expected old target
+// did not match the current reference target.
+type WrongOldTargetError struct {
+ Actual string
+ Expected string
+}
+
+// Error implements error.
+func (err *WrongOldTargetError) Error() string {
+ return fmt.Sprintf(
+ "ref/store: incorrect old target: got %q, expected %q",
+ err.Actual, err.Expected,
+ )
+}
diff --git a/ref/store/memory/batch.go b/ref/store/memory/batch.go
new file mode 100644
index 00000000..0326e4f5
--- /dev/null
+++ b/ref/store/memory/batch.go
@@ -0,0 +1,206 @@
+package memory
+
+import (
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/ref/store"
+)
+
+// Batch stages in-memory updates for one subset commit.
+type Batch struct {
+ store *Memory
+ ops []queuedUpdate
+}
+
+var _ store.Batch = (*Batch)(nil)
+
+// BeginBatch creates one new in-memory batch.
+func (memory *Memory) BeginBatch() (store.Batch, error) {
+ return &Batch{
+ store: memory,
+ ops: make([]queuedUpdate, 0, 8),
+ }, nil
+}
+
+// Create queues a direct reference creation.
+func (batch *Batch) Create(name string, newID id.ObjectID) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateCreate, newID: newID})
+}
+
+// Update queues a direct reference update.
+func (batch *Batch) Update(name string, newID, oldID id.ObjectID) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateReplace, newID: newID, oldID: oldID})
+}
+
+// Delete queues a direct reference deletion.
+func (batch *Batch) Delete(name string, oldID id.ObjectID) error {
+ return batch.queue(queuedUpdate{name: name, kind: updateDelete, oldID: oldID})
+}
+
+// Verify queues a direct reference verification.
+func (batch *Batch) Verify(name string, oldID id.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() ([]store.BatchResult, error) {
+ results := make([]store.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 = store.BatchStatusRejected
+ results[i].Error = err
+
+ continue
+ }
+
+ markFatal(results, batch.ops, i, err)
+
+ return results, err
+ }
+
+ if _, exists := seenTargets[target.name]; exists {
+ results[i].Status = store.BatchStatusRejected
+ results[i].Error = store.ErrDuplicateUpdate
+
+ continue
+ }
+
+ seenTargets[target.name] = struct{}{}
+
+ remainingIdx = append(remainingIdx, i)
+ remainingOps = append(remainingOps, op)
+ }
+
+ return batch.applyRemaining(results, remainingIdx, remainingOps)
+}
+
+// Abort abandons the batch.
+func (batch *Batch) Abort() error {
+ return nil
+}
+
+// applyRemaining repeatedly prepares the remaining operations,
+// dropping one rejected operation per round,
+// until either the whole set applies cleanly or a fatal failure occurs.
+func (batch *Batch) applyRemaining(results []store.BatchResult, remainingIdx []int, remainingOps []queuedUpdate) ([]store.BatchResult, error) {
+ for len(remainingOps) > 0 {
+ prepared, failedName, 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 = store.BatchStatusApplied
+ }
+
+ return results, nil
+ }
+
+ if !isBatchRejected(err) {
+ markFatalRemaining(results, remainingIdx, remainingOps, failedName, err)
+
+ return results, err
+ }
+
+ rejectedAt := indexOfName(remainingOps, failedName)
+ if rejectedAt < 0 {
+ for _, idx := range remainingIdx {
+ results[idx].Status = store.BatchStatusNotAttempted
+ results[idx].Error = err
+ }
+
+ return results, err
+ }
+
+ results[remainingIdx[rejectedAt]].Status = store.BatchStatusRejected
+ results[remainingIdx[rejectedAt]].Error = err
+ remainingIdx = append(remainingIdx[:rejectedAt], remainingIdx[rejectedAt+1:]...)
+ remainingOps = append(remainingOps[:rejectedAt], remainingOps[rejectedAt+1:]...)
+ }
+
+ return results, nil
+}
+
+func (batch *Batch) queue(op queuedUpdate) error {
+ err := validateQueuedUpdate(batch.store.objectFormat, op)
+ if err != nil {
+ return err
+ }
+
+ batch.ops = append(batch.ops, op)
+
+ return nil
+}
+
+func markFatal(results []store.BatchResult, ops []queuedUpdate, at int, err error) {
+ results[at].Status = store.BatchStatusFatal
+ results[at].Error = err
+
+ for j := at + 1; j < len(results); j++ {
+ results[j].Name = ops[j].name
+ results[j].Status = store.BatchStatusNotAttempted
+ results[j].Error = err
+ }
+}
+
+func markFatalRemaining(results []store.BatchResult, remainingIdx []int, remainingOps []queuedUpdate, failedName string, err error) {
+ fatalMarked := false
+
+ for i, idx := range remainingIdx {
+ if !fatalMarked && failedName != "" && remainingOps[i].name == failedName {
+ results[idx].Status = store.BatchStatusFatal
+ results[idx].Error = err
+ fatalMarked = true
+
+ continue
+ }
+
+ results[idx].Status = store.BatchStatusNotAttempted
+ results[idx].Error = err
+ }
+}
+
+func indexOfName(ops []queuedUpdate, name string) int {
+ for i, op := range ops {
+ if op.name == name {
+ return i
+ }
+ }
+
+ return -1
+}
diff --git a/ref/store/memory/batch_test.go b/ref/store/memory/batch_test.go
new file mode 100644
index 00000000..518dc7b9
--- /dev/null
+++ b/ref/store/memory/batch_test.go
@@ -0,0 +1,115 @@
+package memory_test
+
+import (
+ "errors"
+ "testing"
+
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/ref"
+ "lindenii.org/go/furgit/ref/store"
+ "lindenii.org/go/furgit/ref/store/memory"
+)
+
+func TestBatchRejectsDuplicateResolvedTargetAndAppliesRemainder(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ m := memory.New(objectFormat)
+ mainID := objectFormat.Sum([]byte("main"))
+ devID := objectFormat.Sum([]byte("dev"))
+ nextMainID := objectFormat.Sum([]byte("next-main"))
+ nextDevID := objectFormat.Sum([]byte("next-dev"))
+ aliasID := objectFormat.Sum([]byte("alias"))
+
+ seed(t, m, func(tx store.Transaction) {
+ 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)
+ }
+ })
+
+ batch, err := m.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)
+ }
+
+ // Updates the symbolic alias in deref mode,
+ // which resolves to refs/heads/main
+ // and therefore duplicates the first operation.
+ 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 != store.BatchStatusApplied {
+ t.Fatalf("results[0].Status = %v, want applied", results[0].Status)
+ }
+
+ if results[1].Status != store.BatchStatusRejected {
+ t.Fatalf("results[1].Status = %v, want rejected", results[1].Status)
+ }
+
+ if !errors.Is(results[1].Error, store.ErrDuplicateUpdate) {
+ t.Fatalf("results[1].Error = %v, want ErrDuplicateUpdate", results[1].Error)
+ }
+
+ if results[2].Status != store.BatchStatusApplied {
+ t.Fatalf("results[2].Status = %v, want applied", results[2].Status)
+ }
+
+ if got := resolveDirect(t, m, "refs/heads/main").ID; got != nextMainID {
+ t.Fatalf("main after batch = %v, want %v", got, nextMainID)
+ }
+
+ if got := resolveDirect(t, m, "refs/heads/dev").ID; got != nextDevID {
+ t.Fatalf("dev after batch = %v, want %v", got, nextDevID)
+ }
+
+ resolved, err := m.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/doc.go b/ref/store/memory/doc.go
new file mode 100644
index 00000000..37a829b0
--- /dev/null
+++ b/ref/store/memory/doc.go
@@ -0,0 +1,2 @@
+// Package memory provides one in-memory reference store.
+package memory
diff --git a/ref/store/memory/helpers_test.go b/ref/store/memory/helpers_test.go
new file mode 100644
index 00000000..a7973f13
--- /dev/null
+++ b/ref/store/memory/helpers_test.go
@@ -0,0 +1,46 @@
+package memory_test
+
+import (
+ "testing"
+
+ "lindenii.org/go/furgit/ref"
+ "lindenii.org/go/furgit/ref/store"
+ "lindenii.org/go/furgit/ref/store/memory"
+)
+
+// resolveDirect resolves name and asserts that it is a direct reference.
+//
+// Unlike Memory.ResolveToDirect, it does not follow symbolic references.
+func resolveDirect(t *testing.T, memory *memory.Memory, name string) ref.Direct {
+ t.Helper()
+
+ resolved, err := memory.Resolve(name)
+ if err != nil {
+ t.Fatalf("Resolve(%q): %v", name, err)
+ }
+
+ direct, ok := resolved.(ref.Direct)
+ if !ok {
+ t.Fatalf("Resolve(%q) = %T, want ref.Direct", name, resolved)
+ }
+
+ return direct
+}
+
+// seed runs fn against a fresh transaction and commits it,
+// failing the test on any error.
+func seed(t *testing.T, memory *memory.Memory, fn func(tx store.Transaction)) {
+ t.Helper()
+
+ tx, err := memory.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ fn(tx)
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit: %v", err)
+ }
+}
diff --git a/ref/store/memory/memory.go b/ref/store/memory/memory.go
new file mode 100644
index 00000000..3c8f4968
--- /dev/null
+++ b/ref/store/memory/memory.go
@@ -0,0 +1,43 @@
+package memory
+
+import (
+ "sync"
+
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/ref/store"
+)
+
+// Memory reads and writes one in-memory Git reference namespace.
+//
+// Labels: Close-Caller.
+type Memory struct {
+ mu sync.RWMutex //exhaustruct:optional
+ objectFormat id.ObjectFormat
+ refs map[string]storedRef
+}
+
+var (
+ _ store.Reader = (*Memory)(nil)
+ _ store.Transactioner = (*Memory)(nil)
+ _ store.Batcher = (*Memory)(nil)
+)
+
+// New builds one empty in-memory reference store for one object format.
+func New(objectFormat id.ObjectFormat) *Memory {
+ return &Memory{
+ objectFormat: objectFormat,
+ refs: make(map[string]storedRef),
+ }
+}
+
+// ObjectFormat returns the object format used by the store.
+func (memory *Memory) ObjectFormat() id.ObjectFormat {
+ return memory.objectFormat
+}
+
+// Close closes the in-memory reference store.
+//
+// Labels: MT-Unsafe.
+func (memory *Memory) Close() error {
+ return nil
+}
diff --git a/ref/store/memory/read.go b/ref/store/memory/read.go
new file mode 100644
index 00000000..540b7576
--- /dev/null
+++ b/ref/store/memory/read.go
@@ -0,0 +1,78 @@
+package memory
+
+import (
+ "fmt"
+
+ "lindenii.org/go/furgit/ref"
+ "lindenii.org/go/furgit/ref/store"
+)
+
+// Resolve resolves one reference name from the in-memory namespace.
+func (memory *Memory) Resolve(name string) (ref.Ref, error) {
+ memory.mu.RLock()
+ defer memory.mu.RUnlock()
+
+ return publicRef(name, memory.refs[name])
+}
+
+// ResolveToDirect resolves symbolic references
+// until one direct reference is reached.
+func (memory *Memory) ResolveToDirect(name string) (ref.Direct, error) {
+ memory.mu.RLock()
+ defer memory.mu.RUnlock()
+
+ return memory.resolveToDirectLocked(name)
+}
+
+func (memory *Memory) resolveToDirectLocked(name string) (ref.Direct, error) {
+ cur := name
+ seen := make(map[string]struct{})
+
+ for {
+ if _, ok := seen[cur]; ok {
+ return ref.Direct{}, fmt.Errorf("%w: at %q", store.ErrSymbolicCycle, cur)
+ }
+
+ seen[cur] = struct{}{}
+
+ resolved, err := publicRef(cur, memory.refs[cur])
+ if err != nil {
+ return ref.Direct{}, err
+ }
+
+ switch resolved := resolved.(type) {
+ case ref.Direct:
+ return resolved, nil
+ case ref.Symbolic:
+ if resolved.Target == "" {
+ return ref.Direct{}, fmt.Errorf(
+ "%w: symbolic reference %q has empty target",
+ store.ErrInvalidValue, resolved.Name(),
+ )
+ }
+
+ cur = resolved.Target
+ default:
+ panic(fmt.Sprintf("ref/store/memory: unsupported reference type %T", resolved))
+ }
+ }
+}
+
+func publicRef(name string, stored storedRef) (ref.Ref, error) {
+ switch stored.kind {
+ case storedDirect:
+ direct := ref.Direct{RefName: name, ID: stored.id, Peeled: nil}
+ if stored.peeled != nil {
+ peeled := *stored.peeled
+ direct.Peeled = &peeled
+ }
+
+ return direct, nil
+ case storedSymbolic:
+ return ref.Symbolic{RefName: name, Target: stored.target}, nil
+ case storedMissing:
+ return nil, store.ErrReferenceNotFound
+ default:
+ panic(fmt.Sprintf("ref/store/memory: unsupported stored reference kind %d", stored.kind))
+ }
+}
diff --git a/ref/store/memory/read_test.go b/ref/store/memory/read_test.go
new file mode 100644
index 00000000..5c082794
--- /dev/null
+++ b/ref/store/memory/read_test.go
@@ -0,0 +1,80 @@
+package memory_test
+
+import (
+ "errors"
+ "testing"
+
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/ref"
+ "lindenii.org/go/furgit/ref/store"
+ "lindenii.org/go/furgit/ref/store/memory"
+)
+
+func TestResolveSymbolic(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ m := memory.New(objectFormat)
+ mainID := objectFormat.Sum([]byte("main"))
+
+ seed(t, m, func(tx store.Transaction) {
+ 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)
+ }
+ })
+
+ head, err := m.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)
+ }
+
+ direct, err := m.ResolveToDirect("HEAD")
+ if err != nil {
+ t.Fatalf("ResolveToDirect(HEAD): %v", err)
+ }
+
+ if direct.ID != mainID {
+ t.Fatalf("ResolveToDirect(HEAD) ID = %v, want %v", direct.ID, mainID)
+ }
+ })
+ }
+}
+
+func TestResolveMissing(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ m := memory.New(objectFormat)
+
+ _, err := m.Resolve("refs/heads/absent")
+ if err == nil {
+ t.Fatalf("Resolve(absent) succeeded, want ErrReferenceNotFound")
+ }
+
+ if !errors.Is(err, store.ErrReferenceNotFound) {
+ t.Fatalf("Resolve(absent) err = %v, want ErrReferenceNotFound", err)
+ }
+ })
+ }
+}
diff --git a/ref/store/memory/ref.go b/ref/store/memory/ref.go
new file mode 100644
index 00000000..1286c358
--- /dev/null
+++ b/ref/store/memory/ref.go
@@ -0,0 +1,43 @@
+package memory
+
+import "lindenii.org/go/furgit/object/id"
+
+// storedRef is the internal representation of one reference.
+//
+// Unlike the public ref values,
+// it carries no name of its own;
+// the name is the map key.
+type storedRef struct {
+ kind storedKind
+ id id.ObjectID //exhaustruct:optional
+ target string //exhaustruct:optional
+ peeled *id.ObjectID //exhaustruct:optional
+}
+
+type storedKind uint8
+
+const (
+ storedMissing storedKind = iota
+ storedDirect
+ storedSymbolic
+)
+
+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/transaction.go b/ref/store/memory/transaction.go
new file mode 100644
index 00000000..e68f8cab
--- /dev/null
+++ b/ref/store/memory/transaction.go
@@ -0,0 +1,95 @@
+package memory
+
+import (
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/ref/store"
+)
+
+// Transaction stages in-memory updates for one atomic commit.
+type Transaction struct {
+ store *Memory
+ ops []queuedUpdate
+}
+
+var _ store.Transaction = (*Transaction)(nil)
+
+// BeginTransaction creates one new in-memory transaction.
+func (memory *Memory) BeginTransaction() (store.Transaction, error) {
+ return &Transaction{
+ store: memory,
+ ops: make([]queuedUpdate, 0, 8),
+ }, nil
+}
+
+// Create queues a direct reference creation.
+func (tx *Transaction) Create(name string, newID id.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateCreate, newID: newID})
+}
+
+// Update queues a direct reference update.
+func (tx *Transaction) Update(name string, newID, oldID id.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateReplace, newID: newID, oldID: oldID})
+}
+
+// Delete queues a direct reference deletion.
+func (tx *Transaction) Delete(name string, oldID id.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateDelete, oldID: oldID})
+}
+
+// Verify queues a direct reference verification.
+func (tx *Transaction) Verify(name string, oldID id.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(tx.store.objectFormat, op)
+ if err != nil {
+ return err
+ }
+
+ tx.ops = append(tx.ops, op)
+
+ return nil
+}
diff --git a/ref/store/memory/transaction_test.go b/ref/store/memory/transaction_test.go
new file mode 100644
index 00000000..75ac3f88
--- /dev/null
+++ b/ref/store/memory/transaction_test.go
@@ -0,0 +1,96 @@
+package memory_test
+
+import (
+ "errors"
+ "testing"
+
+ "lindenii.org/go/furgit/object/id"
+ "lindenii.org/go/furgit/ref/store"
+ "lindenii.org/go/furgit/ref/store/memory"
+)
+
+func TestTransactionRejectLeavesStoreUnchanged(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ m := memory.New(objectFormat)
+ mainID := objectFormat.Sum([]byte("main"))
+ devID := objectFormat.Sum([]byte("dev"))
+ nextID := objectFormat.Sum([]byte("next"))
+ wrongOld := objectFormat.Sum([]byte("wrong"))
+
+ seed(t, m, func(tx store.Transaction) {
+ 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)
+ }
+ })
+
+ tx, err := m.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 WrongOldIDError")
+ }
+
+ if _, ok := errors.AsType[*store.WrongOldIDError](err); !ok {
+ t.Fatalf("Commit error = %T %v, want *store.WrongOldIDError", err, err)
+ }
+
+ if got := resolveDirect(t, m, "refs/heads/main").ID; got != mainID {
+ t.Fatalf("main after rejected transaction = %v, want %v", got, mainID)
+ }
+
+ if got := resolveDirect(t, m, "refs/heads/dev").ID; got != devID {
+ t.Fatalf("dev after rejected transaction = %v, want %v", got, devID)
+ }
+ })
+ }
+}
+
+func TestTransactionRejectsForeignObjectFormat(t *testing.T) {
+ t.Parallel()
+
+ for _, objectFormat := range id.SupportedObjectFormats() {
+ t.Run(objectFormat.String(), func(t *testing.T) {
+ t.Parallel()
+
+ m := memory.New(objectFormat)
+
+ tx, err := m.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Create("refs/heads/main", id.ObjectID{})
+ if err == nil {
+ t.Fatalf("Create with unset ID succeeded, want ErrInvalidValue")
+ }
+
+ if !errors.Is(err, store.ErrInvalidValue) {
+ t.Fatalf("Create error = %v, want ErrInvalidValue", err)
+ }
+ })
+ }
+}
diff --git a/ref/store/memory/update.go b/ref/store/memory/update.go
new file mode 100644
index 00000000..8e8c6e30
--- /dev/null
+++ b/ref/store/memory/update.go
@@ -0,0 +1,372 @@
+package memory
+
+import (
+ "errors"
+ "fmt"
+
+ "lindenii.org/go/furgit/object/id"
+ refname "lindenii.org/go/furgit/ref/name"
+ "lindenii.org/go/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 id.ObjectID //exhaustruct:optional
+ oldID id.ObjectID //exhaustruct:optional
+ newTarget string //exhaustruct:optional
+ oldTarget string //exhaustruct:optional
+}
+
+type resolvedUpdateTarget struct {
+ name string
+ ref storedRef
+}
+
+type preparedUpdate struct {
+ op queuedUpdate
+ target resolvedUpdateTarget
+}
+
+// validateQueuedUpdate checks one operation at queue time,
+// rejecting malformed names and values
+// before they can enter a transaction or batch.
+func validateQueuedUpdate(objectFormat id.ObjectFormat, op queuedUpdate) error {
+ switch op.kind {
+ case updateCreate, updateReplace:
+ err := refname.ValidateUpdateName(op.name, true)
+ if err != nil {
+ return fmt.Errorf("ref/store/memory: %w", err)
+ }
+
+ if op.newID.ObjectFormat() != objectFormat {
+ return fmt.Errorf("%w: object id format mismatch", store.ErrInvalidValue)
+ }
+ case updateDelete, updateVerify:
+ err := refname.ValidateUpdateName(op.name, false)
+ if err != nil {
+ return fmt.Errorf("ref/store/memory: %w", err)
+ }
+
+ if op.oldID.ObjectFormat() != objectFormat {
+ return fmt.Errorf("%w: object id format mismatch", store.ErrInvalidValue)
+ }
+ case updateCreateSymbolic, updateReplaceSymbolic:
+ err := refname.ValidateUpdateName(op.name, true)
+ if err != nil {
+ return fmt.Errorf("ref/store/memory: %w", err)
+ }
+
+ if op.newTarget == "" {
+ return fmt.Errorf("%w: empty symbolic target", store.ErrInvalidValue)
+ }
+
+ err = refname.ValidateSymbolicTarget(op.name, op.newTarget)
+ if err != nil {
+ return fmt.Errorf("ref/store/memory: %w", err)
+ }
+ case updateDeleteSymbolic, updateVerifySymbolic:
+ err := refname.ValidateUpdateName(op.name, false)
+ if err != nil {
+ return fmt.Errorf("ref/store/memory: %w", err)
+ }
+ default:
+ panic(fmt.Sprintf("ref/store/memory: unsupported update operation %d", op.kind))
+ }
+
+ if op.kind == updateReplaceSymbolic || op.kind == updateDeleteSymbolic || op.kind == updateVerifySymbolic {
+ if op.oldTarget == "" {
+ return fmt.Errorf("%w: empty symbolic old target", store.ErrInvalidValue)
+ }
+ }
+
+ return nil
+}
+
+// prepareUpdates resolves, conflict-checks, and verifies a queued operation
+// set against refs without mutating it.
+// On failure it returns the name of the offending operation alongside the error.
+func prepareUpdates(refs map[string]storedRef, ops []queuedUpdate) ([]preparedUpdate, string, error) {
+ prepared, name, err := resolvePreparedUpdates(refs, ops)
+ if err != nil {
+ return prepared, name, err
+ }
+
+ deleted, written := collectPreparedWrites(prepared)
+ existing := collectVisibleNames(refs)
+
+ for _, name := range written {
+ err = verifyRefnameAvailable(name, existing, written, deleted)
+ if err != nil {
+ return prepared, name, err
+ }
+ }
+
+ name, err = verifyPreparedUpdates(refs, prepared)
+ if err != nil {
+ return prepared, name, err
+ }
+
+ return prepared, "", nil
+}
+
+func resolvePreparedUpdates(refs map[string]storedRef, ops []queuedUpdate) ([]preparedUpdate, string, 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, op.name, err
+ }
+
+ if _, exists := targets[target.name]; exists {
+ return prepared, op.name, store.ErrDuplicateUpdate
+ }
+
+ 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:
+ panic(fmt.Sprintf("ref/store/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("%w: at %q", store.ErrSymbolicCycle, cur)
+ }
+
+ seen[cur] = struct{}{}
+
+ refState := directRead(refs, cur)
+ switch refState.kind {
+ case storedMissing:
+ if !allowMissing {
+ return resolvedUpdateTarget{}, store.ErrReferenceNotFound
+ }
+
+ return resolvedUpdateTarget{name: cur, ref: refState}, nil
+ case storedDirect:
+ return resolvedUpdateTarget{name: cur, ref: refState}, nil
+ case storedSymbolic:
+ if refState.target == "" {
+ return resolvedUpdateTarget{}, fmt.Errorf(
+ "%w: symbolic reference has empty target", store.ErrInvalidValue,
+ )
+ }
+
+ cur = refState.target
+ default:
+ panic(fmt.Sprintf("ref/store/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:
+ default:
+ panic(fmt.Sprintf("ref/store/memory: unsupported update operation %d", item.op.kind))
+ }
+ }
+
+ 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 &store.NameConflictError{Other: existingName}
+ }
+ }
+
+ for _, other := range writes {
+ if other == name {
+ continue
+ }
+
+ if refnamesConflict(name, other) {
+ return &store.NameConflictError{Other: other}
+ }
+ }
+
+ return nil
+}
+
+func refnamesConflict(left, right string) bool {
+ return left == right ||
+ hasPathPrefix(left, right) ||
+ hasPathPrefix(right, left)
+}
+
+func hasPathPrefix(name, prefix string) bool {
+ return len(name) > len(prefix) &&
+ name[len(prefix)] == '/' &&
+ name[:len(prefix)] == prefix
+}
+
+func verifyPreparedUpdates(refs map[string]storedRef, prepared []preparedUpdate) (string, error) {
+ for i := range prepared {
+ item := &prepared[i]
+ item.target.ref = directRead(refs, item.target.name)
+
+ err := verifyPreparedUpdateCurrent(*item)
+ if err != nil {
+ return item.op.name, err
+ }
+ }
+
+ return "", nil
+}
+
+func verifyPreparedUpdateCurrent(item preparedUpdate) error {
+ switch item.op.kind {
+ case updateCreate, updateCreateSymbolic:
+ if item.target.ref.kind != storedMissing {
+ return store.ErrCreateExists
+ }
+
+ return nil
+ case updateReplace, updateDelete, updateVerify:
+ if item.target.ref.kind == storedMissing {
+ return store.ErrReferenceNotFound
+ }
+
+ if item.target.ref.kind != storedDirect {
+ return store.ErrExpectedDirect
+ }
+
+ if item.target.ref.id != item.op.oldID {
+ return &store.WrongOldIDError{Actual: item.target.ref.id, Expected: item.op.oldID}
+ }
+
+ return nil
+ case updateReplaceSymbolic, updateDeleteSymbolic, updateVerifySymbolic:
+ if item.target.ref.kind == storedMissing {
+ return store.ErrReferenceNotFound
+ }
+
+ if item.target.ref.kind != storedSymbolic {
+ return store.ErrExpectedSymbolic
+ }
+
+ if item.target.ref.target != item.op.oldTarget {
+ return &store.WrongOldTargetError{Actual: item.target.ref.target, Expected: item.op.oldTarget}
+ }
+
+ return nil
+ default:
+ panic(fmt.Sprintf("ref/store/memory: unsupported update operation %d", item.op.kind))
+ }
+}
+
+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: storedDirect, id: item.op.newID}
+ case updateCreateSymbolic, updateReplaceSymbolic:
+ refs[item.target.name] = storedRef{kind: storedSymbolic, target: item.op.newTarget}
+ case updateDelete, updateDeleteSymbolic:
+ delete(refs, item.target.name)
+ case updateVerify, updateVerifySymbolic:
+ default:
+ panic(fmt.Sprintf("ref/store/memory: unsupported update operation %d", item.op.kind))
+ }
+ }
+}
+
+// isBatchRejected reports whether err is a per-operation rejection
+// that should drop only the offending operation,
+// rather than a fatal failure that aborts the whole batch.
+func isBatchRejected(err error) bool {
+ switch {
+ case errors.Is(err, store.ErrReferenceNotFound),
+ errors.Is(err, store.ErrCreateExists),
+ errors.Is(err, store.ErrDuplicateUpdate),
+ errors.Is(err, store.ErrExpectedDirect),
+ errors.Is(err, store.ErrExpectedSymbolic),
+ errors.Is(err, store.ErrInvalidValue),
+ errors.Is(err, store.ErrSymbolicCycle),
+ errors.Is(err, refname.ErrInvalidName):
+ return true
+ }
+
+ if _, ok := errors.AsType[*store.NameConflictError](err); ok {
+ return true
+ }
+
+ if _, ok := errors.AsType[*store.WrongOldIDError](err); ok {
+ return true
+ }
+
+ if _, ok := errors.AsType[*store.WrongOldTargetError](err); ok {
+ return true
+ }
+
+ return false
+}
diff --git a/ref/store/reading.go b/ref/store/reading.go
new file mode 100644
index 00000000..edb8a20e
--- /dev/null
+++ b/ref/store/reading.go
@@ -0,0 +1,34 @@
+package store
+
+import "lindenii.org/go/furgit/ref"
+
+// Reader reads Git references.
+//
+// Labels: MT-Safe.
+type Reader interface {
+ // Resolve resolves a reference name
+ // to either a symbolic or direct ref.
+ //
+ // Implementations return value forms
+ // ([ref.Direct] or [ref.Symbolic]),
+ // not pointer forms.
+ // If the reference does not exist,
+ // implementations return [ErrReferenceNotFound].
+ //
+ // Labels: Life-Parent.
+ Resolve(name string) (ref.Ref, error)
+
+ // ResolveToDirect resolves a reference name to a direct reference,
+ // following symbolic references until one is reached.
+ //
+ // It follows symbolic references only;
+ // it does not peel annotated tag objects.
+ //
+ // Implementations may follow symbolic hops with backend-local lookup.
+ // Callers that need cross-backend symbolic resolution
+ // (for example across a chain of stores)
+ // should prefer repeatedly calling Resolve.
+ //
+ // Labels: Life-Parent.
+ ResolveToDirect(name string) (ref.Direct, error)
+}
diff --git a/ref/store/transaction.go b/ref/store/transaction.go
new file mode 100644
index 00000000..1f61551a
--- /dev/null
+++ b/ref/store/transaction.go
@@ -0,0 +1,57 @@
+package store
+
+import "lindenii.org/go/furgit/object/id"
+
+// Transaction stages reference updates for one atomic commit.
+//
+// Ordinary methods operate in dereference mode:
+// if name resolves to a symbolic ref,
+// the operation applies to the final referent
+// rather than to the symbolic ref itself.
+//
+// Symbolic methods operate on the named reference directly,
+// without dereferencing symbolic refs.
+//
+// Labels: MT-Unsafe.
+type Transaction interface {
+ // Create creates one direct reference,
+ // requiring that the logical reference does not already exist.
+ Create(name string, newID id.ObjectID) error
+
+ // Update updates one direct reference,
+ // requiring that the current logical reference value matches oldID.
+ Update(name string, newID, oldID id.ObjectID) error
+
+ // Delete deletes one direct reference,
+ // requiring that the current logical reference value matches oldID.
+ Delete(name string, oldID id.ObjectID) error
+
+ // Verify verifies that the current logical reference value matches oldID.
+ Verify(name string, oldID id.ObjectID) error
+
+ // CreateSymbolic creates one symbolic reference,
+ // requiring that the named reference does not already exist.
+ CreateSymbolic(name, newTarget string) error
+
+ // UpdateSymbolic updates one symbolic reference directly,
+ // requiring that its current target matches oldTarget.
+ UpdateSymbolic(name, newTarget, oldTarget string) error
+
+ // DeleteSymbolic deletes one symbolic reference directly,
+ // requiring that its current target matches oldTarget.
+ DeleteSymbolic(name, oldTarget string) error
+
+ // VerifySymbolic verifies that the named symbolic reference
+ // currently points at oldTarget.
+ VerifySymbolic(name, oldTarget string) error
+
+ // Commit validates and applies all queued operations atomically.
+ //
+ // Commit invalidates the receiver.
+ Commit() error
+
+ // Abort abandons the transaction and releases any resources it holds.
+ //
+ // Abort invalidates the receiver.
+ Abort() error
+}
diff --git a/ref/store/transactional_store.go b/ref/store/transactional_store.go
new file mode 100644
index 00000000..e8b46413
--- /dev/null
+++ b/ref/store/transactional_store.go
@@ -0,0 +1,13 @@
+package store
+
+// Transactioner begins atomic reference transactions.
+//
+// Implementations should only satisfy Transactioner
+// when they can stage and commit reference updates
+// atomically within that backend.
+type Transactioner interface {
+ // BeginTransaction creates one new mutable transaction.
+ //
+ // Labels: Deps-Borrowed, Life-Parent.
+ BeginTransaction() (Transaction, error)
+}