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