shithub: furgit

Download patch

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)
 )
--