ref: 563a4dfb78aaa97febd0763e9f81a740af0dd666
parent: e083331679dd2d58cfd3a0b9639a6ecd6220ba31
author: Runxi Yu <me@runxiyu.org>
date: Sat Mar 7 14:37:20 EST 2026
refstore/files: Implement batching
--- /dev/null
+++ b/refstore/files/batch.go
@@ -1,0 +1,11 @@
+package files
+
+import "codeberg.org/lindenii/furgit/refstore"
+
+type Batch struct {+ store *Store
+ ops []txOp
+ closed bool
+}
+
+var _ refstore.Batch = (*Batch)(nil)
--- /dev/null
+++ b/refstore/files/batch_abort.go
@@ -1,0 +1,13 @@
+package files
+
+import "errors"
+
+func (batch *Batch) Abort() error {+ if batch.closed {+ return errors.New("refstore/files: batch already closed")+ }
+
+ batch.closed = true
+
+ return nil
+}
--- /dev/null
+++ b/refstore/files/batch_apply.go
@@ -1,0 +1,70 @@
+package files
+
+import (
+ "errors"
+
+ "codeberg.org/lindenii/furgit/refstore"
+)
+
+func (batch *Batch) Apply() ([]refstore.BatchResult, error) {+ if batch.closed {+ return nil, errors.New("refstore/files: batch already closed")+ }
+
+ results := make([]refstore.BatchResult, len(batch.ops))
+ seen := make(map[string]struct{}, len(batch.ops))+
+ for i, op := range batch.ops {+ results[i].Name = op.name
+
+ if _, exists := seen[op.name]; exists {+ batch.closed = true
+
+ err := errors.New("refstore/files: duplicate batch operation for " + `"` + op.name + `"`)+ for j := i; j < len(results); j++ {+ results[j].Name = batch.ops[j].name
+ results[j].Error = err
+ }
+
+ return results, err
+ }
+
+ seen[op.name] = struct{}{}+ }
+
+ for i, op := range batch.ops {+ tx := &Transaction{+ store: batch.store,
+ ops: []txOp{op},+ }
+
+ if err := tx.validateOp(op); err != nil {+ results[i].Error = err
+ continue
+ }
+
+ err := tx.Commit()
+ if err == nil {+ continue
+ }
+
+ if isBatchRejected(err) {+ results[i].Error = err
+ continue
+ }
+
+ batch.closed = true
+ results[i].Error = err
+
+ for j := i + 1; j < len(results); j++ {+ results[j].Name = batch.ops[j].name
+ results[j].Error = err
+ }
+
+ return results, err
+ }
+
+ batch.closed = true
+
+ return results, nil
+}
--- /dev/null
+++ b/refstore/files/batch_begin.go
@@ -1,0 +1,13 @@
+package files
+
+import "codeberg.org/lindenii/furgit/refstore"
+
+// BeginBatch creates one new files batch.
+//
+//nolint:ireturn
+func (store *Store) BeginBatch() (refstore.Batch, error) {+ return &Batch{+ store: store,
+ ops: make([]txOp, 0, 8),
+ }, nil
+}
--- /dev/null
+++ b/refstore/files/batch_queue.go
@@ -1,0 +1,9 @@
+package files
+
+func (batch *Batch) queue(op txOp) {+ if batch.closed {+ return
+ }
+
+ batch.ops = append(batch.ops, op)
+}
--- /dev/null
+++ b/refstore/files/batch_queue_ops.go
@@ -1,0 +1,35 @@
+package files
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+func (batch *Batch) Create(name string, newID objectid.ObjectID) {+ batch.queue(txOp{name: name, kind: txCreate, newID: newID})+}
+
+func (batch *Batch) Update(name string, newID, oldID objectid.ObjectID) {+ batch.queue(txOp{name: name, kind: txUpdate, newID: newID, oldID: oldID})+}
+
+func (batch *Batch) Delete(name string, oldID objectid.ObjectID) {+ batch.queue(txOp{name: name, kind: txDelete, oldID: oldID})+}
+
+func (batch *Batch) Verify(name string, oldID objectid.ObjectID) {+ batch.queue(txOp{name: name, kind: txVerify, oldID: oldID})+}
+
+func (batch *Batch) CreateSymbolic(name, newTarget string) {+ batch.queue(txOp{name: name, kind: txCreateSymbolic, newTarget: newTarget})+}
+
+func (batch *Batch) UpdateSymbolic(name, newTarget, oldTarget string) {+ batch.queue(txOp{name: name, kind: txUpdateSymbolic, newTarget: newTarget, oldTarget: oldTarget})+}
+
+func (batch *Batch) DeleteSymbolic(name, oldTarget string) {+ batch.queue(txOp{name: name, kind: txDeleteSymbolic, oldTarget: oldTarget})+}
+
+func (batch *Batch) VerifySymbolic(name, oldTarget string) {+ batch.queue(txOp{name: name, kind: txVerifySymbolic, oldTarget: oldTarget})+}
--- /dev/null
+++ b/refstore/files/batch_reject.go
@@ -1,0 +1,35 @@
+package files
+
+import (
+ "errors"
+ "strings"
+
+ "codeberg.org/lindenii/furgit/objectid"
+ "codeberg.org/lindenii/furgit/ref/refname"
+ "codeberg.org/lindenii/furgit/refstore"
+)
+
+func isBatchRejected(err error) bool {+ var nameErr *refname.NameError
+
+ if errors.As(err, &nameErr) {+ return true
+ }
+
+ if errors.Is(err, objectid.ErrInvalidAlgorithm) || errors.Is(err, refstore.ErrReferenceNotFound) {+ return true
+ }
+
+ msg := err.Error()
+
+ return strings.Contains(msg, "empty reference name") ||
+ strings.Contains(msg, "empty symbolic target") ||
+ strings.Contains(msg, "empty symbolic old target") ||
+ strings.Contains(msg, "already exists") ||
+ strings.Contains(msg, "is missing") ||
+ strings.Contains(msg, "is not detached") ||
+ strings.Contains(msg, "is not symbolic") ||
+ strings.Contains(msg, "expected") ||
+ strings.Contains(msg, "reference name conflict") ||
+ strings.Contains(msg, "non-empty directory blocks reference")
+}
--- /dev/null
+++ b/refstore/files/batch_test.go
@@ -1,0 +1,98 @@
+package files_test
+
+import (
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ "codeberg.org/lindenii/furgit/objectid"
+)
+
+func TestBatchApplyRejectsStaleDeleteAndAppliesIndependentDelete(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ _, _, staleID := testRepo.MakeCommit(t, "stale")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.UpdateRef(t, "refs/heads/topic", commitID)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ batch, err := store.BeginBatch()
+ if err != nil {+ t.Fatalf("BeginBatch: %v", err)+ }
+
+ batch.Delete("refs/heads/main", staleID)+ batch.Delete("refs/heads/topic", commitID)+
+ results, err := batch.Apply()
+ if err != nil {+ t.Fatalf("Apply: %v", err)+ }
+
+ if len(results) != 2 {+ t.Fatalf("len(results) = %d, want 2", len(results))+ }
+
+ if results[0].Error == nil {+ t.Fatal("stale delete unexpectedly succeeded")+ }
+
+ if results[1].Error != nil {+ t.Fatalf("valid delete failed: %v", results[1].Error)+ }
+
+ if _, err := store.Resolve("refs/heads/main"); err != nil {+ t.Fatalf("Resolve(main): %v", err)+ }
+
+ if _, err := store.Resolve("refs/heads/topic"); err == nil {+ t.Fatal("refs/heads/topic still exists")+ }
+ })
+}
+
+func TestBatchApplyRejectsDuplicateQueuedRef(t *testing.T) {+ t.Parallel()
+
+ //nolint:thelper
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) {+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ batch, err := store.BeginBatch()
+ if err != nil {+ t.Fatalf("BeginBatch: %v", err)+ }
+
+ batch.Delete("refs/heads/main", commitID)+ batch.Verify("refs/heads/main", commitID)+
+ results, err := batch.Apply()
+ if err == nil {+ t.Fatal("Apply unexpectedly succeeded")+ }
+
+ if len(results) != 2 {+ t.Fatalf("len(results) = %d, want 2", len(results))+ }
+
+ if results[1].Error == nil {+ t.Fatal("duplicate ref operation did not report an error")+ }
+
+ if _, err := store.Resolve("refs/heads/main"); err != nil {+ t.Fatalf("Resolve(main): %v", err)+ }
+ })
+}
--- a/refstore/files/store.go
+++ b/refstore/files/store.go
@@ -27,4 +27,5 @@
var (
_ refstore.ReadingStore = (*Store)(nil)
_ refstore.TransactionalStore = (*Store)(nil)
+ _ refstore.BatchStore = (*Store)(nil)
)
--
⑨