diff options
Diffstat (limited to 'ref/store/memory')
| -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 |
11 files changed, 1176 insertions, 0 deletions
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 +} |
