diff options
| author | 2026-06-24 14:52:28 +0000 | |
|---|---|---|
| committer | 2026-06-24 14:53:00 +0000 | |
| commit | db5e5eb40d7f8652383099a2154f00158aa476c2 (patch) | |
| tree | e8c60130234137fc59505a360e804917f6c6f01e /ref | |
| parent | TODO: maint, gc (diff) | |
ref/store{,/memory}: Add
Diffstat (limited to 'ref')
| -rw-r--r-- | ref/store/batch.go | 84 | ||||
| -rw-r--r-- | ref/store/batch_store.go | 9 | ||||
| -rw-r--r-- | ref/store/doc.go | 18 | ||||
| -rw-r--r-- | ref/store/errors.go | 77 | ||||
| -rw-r--r-- | ref/store/memory/batch.go | 206 | ||||
| -rw-r--r-- | ref/store/memory/batch_test.go | 115 | ||||
| -rw-r--r-- | ref/store/memory/doc.go | 2 | ||||
| -rw-r--r-- | ref/store/memory/helpers_test.go | 46 | ||||
| -rw-r--r-- | ref/store/memory/memory.go | 43 | ||||
| -rw-r--r-- | ref/store/memory/read.go | 78 | ||||
| -rw-r--r-- | ref/store/memory/read_test.go | 80 | ||||
| -rw-r--r-- | ref/store/memory/ref.go | 43 | ||||
| -rw-r--r-- | ref/store/memory/transaction.go | 95 | ||||
| -rw-r--r-- | ref/store/memory/transaction_test.go | 96 | ||||
| -rw-r--r-- | ref/store/memory/update.go | 372 | ||||
| -rw-r--r-- | ref/store/reading.go | 34 | ||||
| -rw-r--r-- | ref/store/transaction.go | 57 | ||||
| -rw-r--r-- | ref/store/transactional_store.go | 13 |
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) +} |
