aboutsummaryrefslogtreecommitdiff
path: root/ref/store
diff options
context:
space:
mode:
Diffstat (limited to 'ref/store')
-rw-r--r--ref/store/batch.go64
-rw-r--r--ref/store/batch_store.go7
-rw-r--r--ref/store/chain/chain.go12
-rw-r--r--ref/store/chain/close.go8
-rw-r--r--ref/store/chain/list.go40
-rw-r--r--ref/store/chain/new.go13
-rw-r--r--ref/store/chain/resolve.go64
-rw-r--r--ref/store/doc.go2
-rw-r--r--ref/store/errors.go7
-rw-r--r--ref/store/files/batch.go10
-rw-r--r--ref/store/files/batch_abort.go5
-rw-r--r--ref/store/files/batch_apply.go136
-rw-r--r--ref/store/files/batch_begin.go13
-rw-r--r--ref/store/files/batch_queue.go5
-rw-r--r--ref/store/files/batch_queue_ops.go35
-rw-r--r--ref/store/files/batch_rejection.go19
-rw-r--r--ref/store/files/batch_result_error.go21
-rw-r--r--ref/store/files/batch_test.go116
-rw-r--r--ref/store/files/broken_ref_error.go16
-rw-r--r--ref/store/files/close.go11
-rw-r--r--ref/store/files/helpers_test.go150
-rw-r--r--ref/store/files/new.go29
-rw-r--r--ref/store/files/packed_delete_test.go292
-rw-r--r--ref/store/files/packed_parse.go113
-rw-r--r--ref/store/files/packed_read.go35
-rw-r--r--ref/store/files/packed_refs.go10
-rw-r--r--ref/store/files/read_list.go76
-rw-r--r--ref/store/files/read_list_collect.go78
-rw-r--r--ref/store/files/read_loose.go48
-rw-r--r--ref/store/files/read_resolve.go41
-rw-r--r--ref/store/files/read_resolve_fully.go42
-rw-r--r--ref/store/files/resolve_list_test.go269
-rw-r--r--ref/store/files/root_for.go13
-rw-r--r--ref/store/files/root_kind.go8
-rw-r--r--ref/store/files/root_loose_path.go24
-rw-r--r--ref/store/files/root_open_common.go31
-rw-r--r--ref/store/files/store.go32
-rw-r--r--ref/store/files/transaction.go12
-rw-r--r--ref/store/files/transaction_abort.go3
-rw-r--r--ref/store/files/transaction_begin.go13
-rw-r--r--ref/store/files/transaction_commit.go12
-rw-r--r--ref/store/files/transaction_dirs_test.go220
-rw-r--r--ref/store/files/transaction_names_test.go188
-rw-r--r--ref/store/files/transaction_pseudoref_test.go106
-rw-r--r--ref/store/files/transaction_queue.go12
-rw-r--r--ref/store/files/transaction_queue_ops.go35
-rw-r--r--ref/store/files/transaction_symbolic_test.go154
-rw-r--r--ref/store/files/transaction_update_test.go178
-rw-r--r--ref/store/files/trim.go10
-rw-r--r--ref/store/files/update_cleanup.go39
-rw-r--r--ref/store/files/update_cleanup_parents.go35
-rw-r--r--ref/store/files/update_commit.go25
-rw-r--r--ref/store/files/update_commit_delete.go25
-rw-r--r--ref/store/files/update_dir_tree.go59
-rw-r--r--ref/store/files/update_direct_read.go76
-rw-r--r--ref/store/files/update_direct_ref.go20
-rw-r--r--ref/store/files/update_error.go28
-rw-r--r--ref/store/files/update_executor.go5
-rw-r--r--ref/store/files/update_kind.go14
-rw-r--r--ref/store/files/update_lock.go25
-rw-r--r--ref/store/files/update_lock_packed.go44
-rw-r--r--ref/store/files/update_operation_prepared.go6
-rw-r--r--ref/store/files/update_operation_queue.go12
-rw-r--r--ref/store/files/update_path.go28
-rw-r--r--ref/store/files/update_prepare.go48
-rw-r--r--ref/store/files/update_prepare_lock.go29
-rw-r--r--ref/store/files/update_prepare_resolve.go43
-rw-r--r--ref/store/files/update_prepare_verify.go21
-rw-r--r--ref/store/files/update_resolve_target.go21
-rw-r--r--ref/store/files/update_resolve_target_ordinary.go48
-rw-r--r--ref/store/files/update_target_resolved.go7
-rw-r--r--ref/store/files/update_validate.go66
-rw-r--r--ref/store/files/update_verify_current.go60
-rw-r--r--ref/store/files/update_verify_refnames.go41
-rw-r--r--ref/store/files/update_visible_names.go29
-rw-r--r--ref/store/files/update_write_loose.go59
-rw-r--r--ref/store/files/update_write_packed_refs.go98
-rw-r--r--ref/store/files/worktree_test.go206
-rw-r--r--ref/store/read_write_store.go8
-rw-r--r--ref/store/reading.go34
-rw-r--r--ref/store/transaction.go50
-rw-r--r--ref/store/transactional_store.go11
-rw-r--r--ref/store/update_errors.go110
83 files changed, 4268 insertions, 0 deletions
diff --git a/ref/store/batch.go b/ref/store/batch.go
new file mode 100644
index 00000000..6a877a2c
--- /dev/null
+++ b/ref/store/batch.go
@@ -0,0 +1,64 @@
+package refstore
+
+import objectid "codeberg.org/lindenii/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.
+//
+// A batch borrows its underlying store and is invalid after that store is
+// closed.
+type Batch interface {
+ // Create creates one detached reference, requiring that the logical
+ // reference does not already exist.
+ Create(name string, newID objectid.ObjectID)
+ // Update updates one detached reference, requiring that the current logical
+ // reference value matches oldID.
+ Update(name string, newID, oldID objectid.ObjectID)
+ // Delete deletes one detached reference, requiring that the current logical
+ // reference value matches oldID.
+ Delete(name string, oldID objectid.ObjectID)
+ // Verify verifies that the current logical reference value matches oldID.
+ Verify(name string, oldID objectid.ObjectID)
+
+ // CreateSymbolic creates one symbolic reference, requiring that the named
+ // reference does not already exist.
+ CreateSymbolic(name, newTarget string)
+ // UpdateSymbolic updates one symbolic reference directly, requiring that its
+ // current target matches oldTarget.
+ UpdateSymbolic(name, newTarget, oldTarget string)
+ // DeleteSymbolic deletes one symbolic reference directly, requiring that its
+ // current target matches oldTarget.
+ DeleteSymbolic(name, oldTarget string)
+ // VerifySymbolic verifies that the named symbolic reference currently points
+ // at oldTarget.
+ VerifySymbolic(name, oldTarget string)
+
+ // Apply validates and applies queued operations, returning one result per
+ // queued operation in order. Fatal backend failures are returned separately.
+ //
+ // Apply is terminal. Further use of the batch is undefined behavior.
+ Apply() ([]BatchResult, error)
+ // Abort abandons the batch and releases any resources it holds.
+ //
+ // Abort is terminal. Further use of the batch is undefined behavior.
+ Abort() error
+}
+
+// BatchStatus reports the outcome for one queued batch operation.
+type BatchStatus uint8
+
+const (
+ BatchStatusApplied BatchStatus = iota
+ BatchStatusRejected
+ BatchStatusFatal
+ BatchStatusNotAttempted
+)
+
+// BatchResult reports the outcome for one queued batch operation.
+type BatchResult struct {
+ Name string
+ Status BatchStatus
+ Error error
+}
diff --git a/ref/store/batch_store.go b/ref/store/batch_store.go
new file mode 100644
index 00000000..3ccfdd10
--- /dev/null
+++ b/ref/store/batch_store.go
@@ -0,0 +1,7 @@
+package refstore
+
+// BatchStore begins non-atomic reference batches.
+type BatchStore interface {
+ // BeginBatch creates one new queued batch.
+ BeginBatch() (Batch, error)
+}
diff --git a/ref/store/chain/chain.go b/ref/store/chain/chain.go
new file mode 100644
index 00000000..6a4a0eed
--- /dev/null
+++ b/ref/store/chain/chain.go
@@ -0,0 +1,12 @@
+// Package chain provides a wrapper reference storage backend to query a chain
+// of backends.
+package chain
+
+import "codeberg.org/lindenii/furgit/ref/store"
+
+// Chain queries multiple reference stores in order.
+//
+// Chain borrows its backend stores.
+type Chain struct {
+ backends []refstore.ReadingStore
+}
diff --git a/ref/store/chain/close.go b/ref/store/chain/close.go
new file mode 100644
index 00000000..6bd74565
--- /dev/null
+++ b/ref/store/chain/close.go
@@ -0,0 +1,8 @@
+package chain
+
+// Close releases wrapper-local resources.
+//
+// Chain borrows its backends, so Close does not close them.
+//
+// Repeated calls to Close are undefined behavior.
+func (chain *Chain) Close() error { return nil }
diff --git a/ref/store/chain/list.go b/ref/store/chain/list.go
new file mode 100644
index 00000000..c577ca85
--- /dev/null
+++ b/ref/store/chain/list.go
@@ -0,0 +1,40 @@
+package chain
+
+import (
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/ref"
+)
+
+// List lists references from every backend and deduplicates by ref name.
+//
+// First-seen wins, so earlier backends have precedence.
+func (chain *Chain) List(pattern string) ([]ref.Ref, error) {
+ var refs []ref.Ref
+
+ seen := map[string]struct{}{}
+
+ for i, backend := range chain.backends {
+ listed, err := backend.List(pattern)
+ if err != nil {
+ return nil, fmt.Errorf("refstore: backend %d list: %w", i, err)
+ }
+
+ for _, entry := range listed {
+ if entry == nil {
+ continue
+ }
+
+ name := entry.Name()
+ if _, ok := seen[name]; ok {
+ continue
+ }
+
+ seen[name] = struct{}{}
+
+ refs = append(refs, entry)
+ }
+ }
+
+ return refs, nil
+}
diff --git a/ref/store/chain/new.go b/ref/store/chain/new.go
new file mode 100644
index 00000000..dc8c0779
--- /dev/null
+++ b/ref/store/chain/new.go
@@ -0,0 +1,13 @@
+package chain
+
+import "codeberg.org/lindenii/furgit/ref/store"
+
+// New creates an ordered reference store chain.
+//
+// The provided backends must be non-nil and distinct.
+// Chain borrows the provided backends and does not close them in Close.
+func New(backends ...refstore.ReadingStore) *Chain {
+ return &Chain{
+ backends: append([]refstore.ReadingStore(nil), backends...),
+ }
+}
diff --git a/ref/store/chain/resolve.go b/ref/store/chain/resolve.go
new file mode 100644
index 00000000..f69d51ef
--- /dev/null
+++ b/ref/store/chain/resolve.go
@@ -0,0 +1,64 @@
+package chain
+
+import (
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/ref"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// Resolve resolves a reference from the first backend that has it.
+//
+//nolint:ireturn
+func (chain *Chain) Resolve(name string) (ref.Ref, error) {
+ for i, backend := range chain.backends {
+ resolved, err := backend.Resolve(name)
+ if err == nil {
+ return resolved, nil
+ }
+
+ if errors.Is(err, refstore.ErrReferenceNotFound) {
+ continue
+ }
+
+ return nil, fmt.Errorf("refstore: backend %d resolve: %w", i, err)
+ }
+
+ return nil, refstore.ErrReferenceNotFound
+}
+
+// ResolveToDetached resolves symbolic references through Resolve until detached.
+//
+// It intentionally does not call backend ResolveToDetached. This allows symbolic
+// references to cross backends in the chain.
+func (chain *Chain) ResolveToDetached(name string) (ref.Detached, error) {
+ cur := name
+
+ seen := map[string]struct{}{}
+ for {
+ if _, ok := seen[cur]; ok {
+ return ref.Detached{}, fmt.Errorf("refstore: symbolic reference cycle at %q", cur)
+ }
+
+ seen[cur] = struct{}{}
+
+ resolved, err := chain.Resolve(cur)
+ if err != nil {
+ return ref.Detached{}, err
+ }
+
+ switch resolved := resolved.(type) {
+ case ref.Detached:
+ return resolved, nil
+ case ref.Symbolic:
+ if resolved.Target == "" {
+ return ref.Detached{}, fmt.Errorf("refstore: symbolic reference %q has empty target", resolved.Name())
+ }
+
+ cur = resolved.Target
+ default:
+ return ref.Detached{}, fmt.Errorf("refstore: unsupported reference type %T", resolved)
+ }
+ }
+}
diff --git a/ref/store/doc.go b/ref/store/doc.go
new file mode 100644
index 00000000..3d6f3908
--- /dev/null
+++ b/ref/store/doc.go
@@ -0,0 +1,2 @@
+// Package refstore provides interfaces for reference storage backends.
+package refstore
diff --git a/ref/store/errors.go b/ref/store/errors.go
new file mode 100644
index 00000000..45583440
--- /dev/null
+++ b/ref/store/errors.go
@@ -0,0 +1,7 @@
+package refstore
+
+import "errors"
+
+// ErrReferenceNotFound indicates that a reference does not exist in a backend.
+// TODO: Interface error? Just like object not found in objectstore.
+var ErrReferenceNotFound = errors.New("refstore: reference not found")
diff --git a/ref/store/files/batch.go b/ref/store/files/batch.go
new file mode 100644
index 00000000..8f514422
--- /dev/null
+++ b/ref/store/files/batch.go
@@ -0,0 +1,10 @@
+package files
+
+import "codeberg.org/lindenii/furgit/ref/store"
+
+type Batch struct {
+ store *Store
+ ops []queuedUpdate
+}
+
+var _ refstore.Batch = (*Batch)(nil)
diff --git a/ref/store/files/batch_abort.go b/ref/store/files/batch_abort.go
new file mode 100644
index 00000000..0cbd1651
--- /dev/null
+++ b/ref/store/files/batch_abort.go
@@ -0,0 +1,5 @@
+package files
+
+func (batch *Batch) Abort() error {
+ return nil
+}
diff --git a/ref/store/files/batch_apply.go b/ref/store/files/batch_apply.go
new file mode 100644
index 00000000..d6fb1a4d
--- /dev/null
+++ b/ref/store/files/batch_apply.go
@@ -0,0 +1,136 @@
+package files
+
+import "codeberg.org/lindenii/furgit/ref/store"
+
+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))
+ executor := &refUpdateExecutor{store: batch.store}
+
+ for i, op := range batch.ops {
+ results[i].Name = op.name
+
+ err := executor.validateQueuedUpdate(op)
+ if err != nil {
+ results[i].Status = refstore.BatchStatusRejected
+ results[i].Error = batchResultError(err)
+
+ continue
+ }
+
+ target, err := executor.resolveQueuedUpdateTarget(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
+ }
+
+ targetKey := updateTargetKey(target.loc)
+ if _, exists := seenTargets[targetKey]; exists {
+ results[i].Status = refstore.BatchStatusRejected
+ results[i].Error = &refstore.DuplicateUpdateError{}
+
+ continue
+ }
+
+ seenTargets[targetKey] = struct{}{}
+
+ remainingIdx = append(remainingIdx, i)
+ remainingOps = append(remainingOps, op)
+ }
+
+ for len(remainingOps) > 0 {
+ prepared, err := executor.prepareUpdates(remainingOps)
+ if err == nil {
+ err = executor.commitPreparedUpdates(prepared)
+ if err == nil {
+ for _, idx := range remainingIdx {
+ results[idx].Status = refstore.BatchStatusApplied
+ }
+
+ return results, nil
+ }
+
+ 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
+ }
+
+ 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
+}
diff --git a/ref/store/files/batch_begin.go b/ref/store/files/batch_begin.go
new file mode 100644
index 00000000..9c2b98d2
--- /dev/null
+++ b/ref/store/files/batch_begin.go
@@ -0,0 +1,13 @@
+package files
+
+import "codeberg.org/lindenii/furgit/ref/store"
+
+// BeginBatch creates one new files batch.
+//
+//nolint:ireturn
+func (store *Store) BeginBatch() (refstore.Batch, error) {
+ return &Batch{
+ store: store,
+ ops: make([]queuedUpdate, 0, 8),
+ }, nil
+}
diff --git a/ref/store/files/batch_queue.go b/ref/store/files/batch_queue.go
new file mode 100644
index 00000000..5937c6fb
--- /dev/null
+++ b/ref/store/files/batch_queue.go
@@ -0,0 +1,5 @@
+package files
+
+func (batch *Batch) queue(op queuedUpdate) {
+ batch.ops = append(batch.ops, op)
+}
diff --git a/ref/store/files/batch_queue_ops.go b/ref/store/files/batch_queue_ops.go
new file mode 100644
index 00000000..7434b0c3
--- /dev/null
+++ b/ref/store/files/batch_queue_ops.go
@@ -0,0 +1,35 @@
+package files
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+func (batch *Batch) Create(name string, newID objectid.ObjectID) {
+ batch.queue(queuedUpdate{name: name, kind: updateCreate, newID: newID})
+}
+
+func (batch *Batch) Update(name string, newID, oldID objectid.ObjectID) {
+ batch.queue(queuedUpdate{name: name, kind: updateReplace, newID: newID, oldID: oldID})
+}
+
+func (batch *Batch) Delete(name string, oldID objectid.ObjectID) {
+ batch.queue(queuedUpdate{name: name, kind: updateDelete, oldID: oldID})
+}
+
+func (batch *Batch) Verify(name string, oldID objectid.ObjectID) {
+ batch.queue(queuedUpdate{name: name, kind: updateVerify, oldID: oldID})
+}
+
+func (batch *Batch) CreateSymbolic(name, newTarget string) {
+ batch.queue(queuedUpdate{name: name, kind: updateCreateSymbolic, newTarget: newTarget})
+}
+
+func (batch *Batch) UpdateSymbolic(name, newTarget, oldTarget string) {
+ batch.queue(queuedUpdate{name: name, kind: updateReplaceSymbolic, newTarget: newTarget, oldTarget: oldTarget})
+}
+
+func (batch *Batch) DeleteSymbolic(name, oldTarget string) {
+ batch.queue(queuedUpdate{name: name, kind: updateDeleteSymbolic, oldTarget: oldTarget})
+}
+
+func (batch *Batch) VerifySymbolic(name, oldTarget string) {
+ batch.queue(queuedUpdate{name: name, kind: updateVerifySymbolic, oldTarget: oldTarget})
+}
diff --git a/ref/store/files/batch_rejection.go b/ref/store/files/batch_rejection.go
new file mode 100644
index 00000000..a1f8e39c
--- /dev/null
+++ b/ref/store/files/batch_rejection.go
@@ -0,0 +1,19 @@
+package files
+
+import (
+ "errors"
+
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func isBatchRejected(err error) bool {
+ return errors.Is(err, refstore.ErrReferenceNotFound) ||
+ errors.As(err, new(*refstore.InvalidNameError)) ||
+ errors.As(err, new(*refstore.InvalidValueError)) ||
+ errors.As(err, new(*refstore.DuplicateUpdateError)) ||
+ errors.As(err, new(*refstore.CreateExistsError)) ||
+ errors.As(err, new(*refstore.IncorrectOldValueError)) ||
+ errors.As(err, new(*refstore.ExpectedDetachedError)) ||
+ errors.As(err, new(*refstore.ExpectedSymbolicError)) ||
+ errors.As(err, new(*refstore.NameConflictError))
+}
diff --git a/ref/store/files/batch_result_error.go b/ref/store/files/batch_result_error.go
new file mode 100644
index 00000000..06d68273
--- /dev/null
+++ b/ref/store/files/batch_result_error.go
@@ -0,0 +1,21 @@
+package files
+
+import "errors"
+
+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
+}
diff --git a/ref/store/files/batch_test.go b/ref/store/files/batch_test.go
new file mode 100644
index 00000000..2a4eb055
--- /dev/null
+++ b/ref/store/files/batch_test.go
@@ -0,0 +1,116 @@
+package files_test
+
+import (
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+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].Status != refstore.BatchStatusRejected {
+ t.Fatalf("results[0].Status = %v, want rejected", results[0].Status)
+ }
+
+ if !errors.Is(results[0].Error, refstore.ErrReferenceNotFound) &&
+ errors.As(results[0].Error, new(*refstore.IncorrectOldValueError)) == false {
+ t.Fatalf("results[0].Error = %v, want stale-value rejection", results[0].Error)
+ }
+
+ if results[1].Status != refstore.BatchStatusApplied {
+ t.Fatalf("results[1].Status = %v, want applied", results[1].Status)
+ }
+
+ _, err = store.Resolve("refs/heads/main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ _, err = store.Resolve("refs/heads/topic")
+ if 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.Fatalf("Apply: %v", err)
+ }
+
+ if len(results) != 2 {
+ t.Fatalf("len(results) = %d, want 2", 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)
+ }
+
+ if !errors.As(results[1].Error, new(*refstore.DuplicateUpdateError)) {
+ t.Fatalf("results[1].Error = %v, want duplicate update error", results[1].Error)
+ }
+
+ _, err = store.Resolve("refs/heads/main")
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+ })
+}
diff --git a/ref/store/files/broken_ref_error.go b/ref/store/files/broken_ref_error.go
new file mode 100644
index 00000000..daa40849
--- /dev/null
+++ b/ref/store/files/broken_ref_error.go
@@ -0,0 +1,16 @@
+package files
+
+import "fmt"
+
+type brokenRefError struct {
+ name string
+ err error
+}
+
+func (err brokenRefError) Error() string {
+ return fmt.Sprintf("refstore/files: broken reference %q: %v", err.name, err.err)
+}
+
+func (err brokenRefError) Unwrap() error {
+ return err.err
+}
diff --git a/ref/store/files/close.go b/ref/store/files/close.go
new file mode 100644
index 00000000..58f400a5
--- /dev/null
+++ b/ref/store/files/close.go
@@ -0,0 +1,11 @@
+package files
+
+// Close releases resources associated with the store.
+//
+// Store borrows gitRoot, so Close does not close it.
+// Transactions and batches borrowing the store are invalid after Close.
+//
+// Repeated calls to Close are undefined behavior.
+func (store *Store) Close() error {
+ return store.commonRoot.Close()
+}
diff --git a/ref/store/files/helpers_test.go b/ref/store/files/helpers_test.go
new file mode 100644
index 00000000..c46cc9fc
--- /dev/null
+++ b/ref/store/files/helpers_test.go
@@ -0,0 +1,150 @@
+package files_test
+
+import (
+ "os"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref/store/files"
+)
+
+const testPackedRefsTimeout = time.Second
+
+func openFilesStore(t *testing.T, testRepo *testgit.TestRepo, algo objectid.Algorithm) *files.Store {
+ t.Helper()
+
+ root := testRepo.OpenGitRoot(t)
+
+ store, err := files.New(root, algo, testPackedRefsTimeout)
+ if err != nil {
+ t.Fatalf("files.New: %v", err)
+ }
+
+ return store
+}
+
+func openFilesStoreAt(t *testing.T, root *os.Root, algo objectid.Algorithm) *files.Store {
+ t.Helper()
+
+ store, err := files.New(root, algo, testPackedRefsTimeout)
+ if err != nil {
+ t.Fatalf("files.New: %v", err)
+ }
+
+ return store
+}
+
+func openGitRootUnder(t *testing.T, repoRoot *os.Root, worktreeName string) *os.Root {
+ t.Helper()
+
+ worktreeRoot, err := repoRoot.OpenRoot(worktreeName)
+ if err != nil {
+ t.Fatalf("OpenRoot(%q): %v", worktreeName, err)
+ }
+
+ t.Cleanup(func() {
+ _ = worktreeRoot.Close()
+ })
+
+ info, err := worktreeRoot.Stat(".git")
+ if err != nil {
+ t.Fatalf("stat %q: %v", worktreeName+"/.git", err)
+ }
+
+ if info.IsDir() {
+ gitRoot, err := worktreeRoot.OpenRoot(".git")
+ if err != nil {
+ t.Fatalf("OpenRoot(.git): %v", err)
+ }
+
+ t.Cleanup(func() {
+ _ = gitRoot.Close()
+ })
+
+ return gitRoot
+ }
+
+ content, err := worktreeRoot.ReadFile(".git")
+ if err != nil {
+ t.Fatalf("read %q: %v", worktreeName+"/.git", err)
+ }
+
+ gitDir := strings.TrimSpace(strings.TrimPrefix(string(content), "gitdir:"))
+ if gitDir == "" {
+ t.Fatalf("%q does not contain a gitdir path", worktreeName+"/.git")
+ }
+
+ if strings.HasPrefix(gitDir, "/") {
+ gitRoot, err := os.OpenRoot(gitDir)
+ if err != nil {
+ t.Fatalf("os.OpenRoot(%q): %v", gitDir, err)
+ }
+
+ t.Cleanup(func() {
+ _ = gitRoot.Close()
+ })
+
+ return gitRoot
+ }
+
+ gitRoot, err := worktreeRoot.OpenRoot(gitDir)
+ if err != nil {
+ t.Fatalf("os.OpenRoot(%q): %v", gitDir, err)
+ }
+
+ t.Cleanup(func() {
+ _ = gitRoot.Close()
+ })
+
+ return gitRoot
+}
+
+func assertListMatchesGitForEachRef(t *testing.T, gitOut string, store *files.Store) {
+ t.Helper()
+
+ listed, err := store.List("")
+ if err != nil {
+ t.Fatalf("List(\"\"): %v", err)
+ }
+
+ gotNames := make([]string, 0, len(listed))
+ for _, got := range listed {
+ if got.Name() == "HEAD" {
+ continue
+ }
+
+ gotNames = append(gotNames, got.Name())
+ }
+
+ slices.Sort(gotNames)
+
+ wantLines := strings.Split(strings.TrimSpace(gitOut), "\n")
+ wantNames := make([]string, 0, len(wantLines))
+
+ for _, line := range wantLines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ wantNames = append(wantNames, line)
+ }
+
+ slices.Sort(wantNames)
+
+ if !slices.Equal(gotNames, wantNames) {
+ t.Fatalf("List names = %v, want %v", gotNames, wantNames)
+ }
+}
+
+func forEachRefLines(output string) []string {
+ if strings.TrimSpace(output) == "" {
+ return nil
+ }
+
+ return strings.Split(strings.TrimSpace(output), "\n")
+}
diff --git a/ref/store/files/new.go b/ref/store/files/new.go
new file mode 100644
index 00000000..bca3a491
--- /dev/null
+++ b/ref/store/files/new.go
@@ -0,0 +1,29 @@
+package files
+
+import (
+ "math/rand"
+ "os"
+ "time"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// New creates one files ref store rooted at one repository gitdir.
+func New(root *os.Root, algo objectid.Algorithm, packedRefsTimeout time.Duration) (*Store, error) {
+ if algo.Size() == 0 {
+ return nil, objectid.ErrInvalidAlgorithm
+ }
+
+ commonRoot, err := openCommonRoot(root)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Store{
+ gitRoot: root,
+ commonRoot: commonRoot,
+ algo: algo,
+ lockRand: rand.New(rand.NewSource(time.Now().UnixNano())), //nolint:gosec
+ packedRefsTimeout: packedRefsTimeout,
+ }, nil
+}
diff --git a/ref/store/files/packed_delete_test.go b/ref/store/files/packed_delete_test.go
new file mode 100644
index 00000000..3d14b71a
--- /dev/null
+++ b/ref/store/files/packed_delete_test.go
@@ -0,0 +1,292 @@
+package files_test
+
+import (
+ "errors"
+ "os"
+ "slices"
+ "sync"
+ "testing"
+ "time"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func TestFilesTransactionPackedDeleteFailureLeavesRefsUnchanged(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ t.Run("packed-refs.lock held", func(t *testing.T) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true, RefFormat: "files"})
+ _, _, packedID := testRepo.MakeCommit(t, "packed")
+ _, _, looseID := testRepo.MakeCommit(t, "loose")
+ prefix := "refs/locked-packed-refs"
+
+ testRepo.UpdateRef(t, prefix+"/foo", packedID)
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.UpdateRef(t, prefix+"/foo", looseID)
+ unchanged := forEachRefLines(testRepo.Run(t, "for-each-ref", "--format=%(objectname) %(refname)", prefix))
+ testRepo.WriteFile(t, "packed-refs.lock", []byte{}, 0o644)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(lock held): %v", err)
+ }
+
+ err = tx.Delete(prefix+"/foo", looseID)
+ if err != nil {
+ t.Fatalf("Delete(lock held) queue: %v", err)
+ }
+
+ err = tx.Commit()
+ if err == nil {
+ t.Fatal("Commit(lock held) unexpectedly succeeded")
+ }
+
+ actual := forEachRefLines(testRepo.Run(t, "for-each-ref", "--format=%(objectname) %(refname)", prefix))
+ if !slices.Equal(actual, unchanged) {
+ t.Fatalf("ShowRef after failed delete = %v, want %v", actual, unchanged)
+ }
+
+ got, err := store.ResolveToDetached(prefix + "/foo")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(lock held): %v", err)
+ }
+
+ if got.ID != looseID {
+ t.Fatalf("ResolveToDetached(lock held) = %s, want %s", got.ID, looseID)
+ }
+
+ gitRoot := testRepo.OpenGitRoot(t)
+
+ _, statErr := gitRoot.Stat(prefix + "/foo.lock")
+ if !errors.Is(statErr, os.ErrNotExist) {
+ t.Fatalf("unexpected leftover loose lock: %v", statErr)
+ }
+ })
+
+ t.Run("packed-refs.new exists", func(t *testing.T) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true, RefFormat: "files"})
+ _, _, packedID := testRepo.MakeCommit(t, "packed")
+ _, _, looseID := testRepo.MakeCommit(t, "loose")
+ prefix := "refs/failed-packed-refs"
+
+ testRepo.UpdateRef(t, prefix+"/foo", packedID)
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.UpdateRef(t, prefix+"/foo", looseID)
+ unchanged := forEachRefLines(testRepo.Run(t, "for-each-ref", "--format=%(objectname) %(refname)", prefix))
+ testRepo.WriteFile(t, "packed-refs.new", []byte{}, 0o644)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(new exists): %v", err)
+ }
+
+ err = tx.Delete(prefix+"/foo", looseID)
+ if err != nil {
+ t.Fatalf("Delete(new exists) queue: %v", err)
+ }
+
+ err = tx.Commit()
+ if err == nil {
+ t.Fatal("Commit(new exists) unexpectedly succeeded")
+ }
+
+ actual := forEachRefLines(testRepo.Run(t, "for-each-ref", "--format=%(objectname) %(refname)", prefix))
+ if !slices.Equal(actual, unchanged) {
+ t.Fatalf("ShowRef after failed delete = %v, want %v", actual, unchanged)
+ }
+
+ got, err := store.ResolveToDetached(prefix + "/foo")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(new exists): %v", err)
+ }
+
+ if got.ID != looseID {
+ t.Fatalf("ResolveToDetached(new exists) = %s, want %s", got.ID, looseID)
+ }
+ })
+ })
+}
+
+func TestFilesPackedRefDeleteDoesNotCreateDirectories(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true, RefFormat: "files"})
+ _, _, commitID := testRepo.MakeCommit(t, "packed-only")
+ name := "refs/heads/d1/d2/r1"
+
+ testRepo.UpdateRef(t, name, commitID)
+ testRepo.PackRefs(t, "--all", "--prune")
+
+ gitRoot := testRepo.OpenGitRoot(t)
+
+ _, err := gitRoot.Stat("refs/heads/d1/d2")
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("refs/heads/d1/d2 unexpectedly exists before delete: %v", err)
+ }
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Delete(name, commitID)
+ if err != nil {
+ t.Fatalf("Delete queue: %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit: %v", err)
+ }
+
+ _, err = gitRoot.Stat("refs/heads/d1/d2")
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("refs/heads/d1/d2 unexpectedly exists after delete: %v", err)
+ }
+
+ _, err = gitRoot.Stat("refs/heads/d1")
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("refs/heads/d1 unexpectedly exists after delete: %v", err)
+ }
+ })
+}
+
+func TestFilesPackedRefIgnoresEmptyDirectories(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true, RefFormat: "files"})
+ _, _, commitID := testRepo.MakeCommit(t, "packed-visible")
+ prefix := "refs/e-for-each-ref"
+ name := prefix + "/foo"
+
+ testRepo.UpdateRef(t, name, commitID)
+ expected := forEachRefLines(testRepo.Run(t, "for-each-ref", "--format=%(objectname) %(refname)", prefix))
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.WriteFileAll(t, prefix+"/foo/bar/baz/.keep", []byte{}, 0o755, 0o644)
+ testRepo.Remove(t, prefix+"/foo/bar/baz/.keep")
+
+ store := openFilesStore(t, testRepo, algo)
+
+ got, err := store.ResolveToDetached(name)
+ if err != nil {
+ t.Fatalf("ResolveToDetached: %v", err)
+ }
+
+ if got.ID != commitID {
+ t.Fatalf("ResolveToDetached = %s, want %s", got.ID, commitID)
+ }
+
+ actual := make([]string, 0)
+
+ listed, err := store.List(prefix + "/*")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+
+ for _, entry := range listed {
+ actual = append(actual, entry.Name())
+ }
+
+ fullActual := make([]string, 0, len(actual))
+ for _, name := range actual {
+ refValue, resolveErr := store.ResolveToDetached(name)
+ if resolveErr != nil {
+ t.Fatalf("ResolveToDetached(%q): %v", name, resolveErr)
+ }
+
+ fullActual = append(fullActual, refValue.ID.String()+" "+name)
+ }
+
+ slices.Sort(fullActual)
+
+ if !slices.Equal(fullActual, expected) {
+ t.Fatalf("for-each-ref view = %v, want %v", fullActual, expected)
+ }
+ })
+}
+
+func TestFilesDeleteWaitsForPackedRefsLockWithoutIntermediateState(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true, RefFormat: "files"})
+ _, _, packedID := testRepo.MakeCommit(t, "packed")
+ _, _, looseID := testRepo.MakeCommit(t, "loose")
+ prefix := "refs/slow-transaction"
+
+ testRepo.UpdateRef(t, prefix+"/foo", packedID)
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.UpdateRef(t, prefix+"/foo", looseID)
+ testRepo.WriteFile(t, "packed-refs.lock", []byte{}, 0o644)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Delete(prefix+"/foo", looseID)
+ if err != nil {
+ t.Fatalf("Delete queue: %v", err)
+ }
+
+ done := make(chan error, 1)
+
+ var wg sync.WaitGroup
+
+ wg.Go(func() {
+ done <- tx.Commit()
+ })
+
+ time.Sleep(75 * time.Millisecond)
+
+ select {
+ case err := <-done:
+ t.Fatalf("Commit finished too early: %v", err)
+ default:
+ }
+
+ got, err := store.ResolveToDetached(prefix + "/foo")
+ if err != nil {
+ t.Fatalf("ResolveToDetached while lock held: %v", err)
+ }
+
+ if got.ID != looseID {
+ t.Fatalf("ResolveToDetached while lock held = %s, want %s", got.ID, looseID)
+ }
+
+ testRepo.Remove(t, "packed-refs.lock")
+
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("Commit after lock release: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Commit did not finish after lock release")
+ }
+
+ wg.Wait()
+
+ _, err = store.Resolve(prefix + "/foo")
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ t.Fatalf("Resolve after delete error = %v, want ErrReferenceNotFound", err)
+ }
+ })
+}
diff --git a/ref/store/files/packed_parse.go b/ref/store/files/packed_parse.go
new file mode 100644
index 00000000..3662f6ed
--- /dev/null
+++ b/ref/store/files/packed_parse.go
@@ -0,0 +1,113 @@
+package files
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "strings"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref"
+)
+
+func parsePackedRefs(r io.Reader, algo objectid.Algorithm) (map[string]ref.Detached, []ref.Detached, error) {
+ byName := make(map[string]ref.Detached)
+ ordered := make([]ref.Detached, 0, 32)
+
+ br := bufio.NewReader(r)
+ prev := -1
+ lineNum := 0
+ hexsz := algo.Size() * 2
+
+ for {
+ line, err := br.ReadString('\n')
+ if err != nil && err != io.EOF {
+ return nil, nil, err
+ }
+
+ if line == "" && err == io.EOF {
+ break
+ }
+
+ lineNum++
+ hadNewline := strings.HasSuffix(line, "\n")
+ line = strings.TrimSuffix(line, "\n")
+
+ if err == io.EOF && !hadNewline {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: unterminated line", lineNum)
+ }
+
+ if line == "" || strings.HasPrefix(line, "#") {
+ if err == io.EOF {
+ break
+ }
+
+ continue
+ }
+
+ if strings.HasPrefix(line, "^") {
+ if prev < 0 {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: peeled line without preceding ref", lineNum)
+ }
+
+ if len(line) != hexsz+1 {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: malformed peeled line", lineNum)
+ }
+
+ peeled, parseErr := objectid.ParseHex(algo, line[1:])
+ if parseErr != nil {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: invalid peeled oid: %w", lineNum, parseErr)
+ }
+
+ peeledCopy := peeled
+ cur := ordered[prev]
+ cur.Peeled = &peeledCopy
+ ordered[prev] = cur
+ byName[cur.Name()] = cur
+
+ if err == io.EOF {
+ break
+ }
+
+ continue
+ }
+
+ if len(line) < hexsz+2 {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: malformed entry", lineNum)
+ }
+
+ if line[hexsz] != ' ' {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: malformed entry", lineNum)
+ }
+
+ idText := line[:hexsz]
+
+ name := line[hexsz+1:]
+ if name == "" {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: empty ref name", lineNum)
+ }
+
+ id, parseErr := objectid.ParseHex(algo, idText)
+ if parseErr != nil {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: invalid oid: %w", lineNum, parseErr)
+ }
+
+ if _, exists := byName[name]; exists {
+ return nil, nil, fmt.Errorf("refstore/files: line %d: duplicate ref %q", lineNum, name)
+ }
+
+ detached := ref.Detached{
+ RefName: name,
+ ID: id,
+ }
+ ordered = append(ordered, detached)
+ prev = len(ordered) - 1
+ byName[name] = detached
+
+ if err == io.EOF {
+ break
+ }
+ }
+
+ return byName, ordered, nil
+}
diff --git a/ref/store/files/packed_read.go b/ref/store/files/packed_read.go
new file mode 100644
index 00000000..20800709
--- /dev/null
+++ b/ref/store/files/packed_read.go
@@ -0,0 +1,35 @@
+package files
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "codeberg.org/lindenii/furgit/ref"
+)
+
+func (store *Store) readPackedRefs() (*packedRefs, error) {
+ file, err := store.commonRoot.Open("packed-refs")
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return &packedRefs{
+ byName: make(map[string]ref.Detached),
+ ordered: nil,
+ }, nil
+ }
+
+ return nil, fmt.Errorf("refstore/files: open packed-refs: %w", err)
+ }
+
+ defer func() { _ = file.Close() }()
+
+ byName, ordered, err := parsePackedRefs(file, store.algo)
+ if err != nil {
+ return nil, err
+ }
+
+ return &packedRefs{
+ byName: byName,
+ ordered: ordered,
+ }, nil
+}
diff --git a/ref/store/files/packed_refs.go b/ref/store/files/packed_refs.go
new file mode 100644
index 00000000..f3e91d83
--- /dev/null
+++ b/ref/store/files/packed_refs.go
@@ -0,0 +1,10 @@
+package files
+
+import (
+ "codeberg.org/lindenii/furgit/ref"
+)
+
+type packedRefs struct {
+ byName map[string]ref.Detached
+ ordered []ref.Detached
+}
diff --git a/ref/store/files/read_list.go b/ref/store/files/read_list.go
new file mode 100644
index 00000000..b8efd046
--- /dev/null
+++ b/ref/store/files/read_list.go
@@ -0,0 +1,76 @@
+package files
+
+import (
+ "errors"
+ "path"
+ "slices"
+
+ "codeberg.org/lindenii/furgit/ref"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// List lists references from the visible files ref 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
+ }
+ }
+
+ looseNames, err := store.collectLooseRefNames()
+ if err != nil {
+ return nil, err
+ }
+
+ packed, err := store.readPackedRefs()
+ if err != nil {
+ return nil, err
+ }
+
+ byName := make(map[string]ref.Ref, len(looseNames)+len(packed.byName))
+ for _, detached := range packed.ordered {
+ byName[detached.Name()] = detached
+ }
+
+ for _, name := range looseNames {
+ resolved, resolveErr := store.readLooseRef(name)
+ if resolveErr != nil {
+ if errors.Is(resolveErr, refstore.ErrReferenceNotFound) {
+ delete(byName, name)
+
+ continue
+ }
+
+ return nil, resolveErr
+ }
+
+ byName[name] = resolved
+ }
+
+ names := make([]string, 0, len(byName))
+ for name := range byName {
+ if !matchAll {
+ matched, matchErr := path.Match(pattern, name)
+ if matchErr != nil {
+ return nil, matchErr
+ }
+
+ if !matched {
+ continue
+ }
+ }
+
+ names = append(names, name)
+ }
+
+ slices.Sort(names)
+
+ refs := make([]ref.Ref, 0, len(names))
+ for _, name := range names {
+ refs = append(refs, byName[name])
+ }
+
+ return refs, nil
+}
diff --git a/ref/store/files/read_list_collect.go b/ref/store/files/read_list_collect.go
new file mode 100644
index 00000000..f4e2cb69
--- /dev/null
+++ b/ref/store/files/read_list_collect.go
@@ -0,0 +1,78 @@
+package files
+
+import (
+ "errors"
+ "os"
+ "path"
+ "strings"
+)
+
+func (store *Store) collectLooseRefNames() ([]string, error) {
+ names := make([]string, 0, 16)
+ seen := make(map[string]struct{}, 16)
+
+ _, err := store.gitRoot.Stat("HEAD")
+ if err == nil {
+ names = append(names, "HEAD")
+ seen["HEAD"] = struct{}{}
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+
+ var walk func(*os.Root, string) error
+
+ walk = func(root *os.Root, dir string) error {
+ file, openErr := root.Open(dir)
+ if openErr != nil {
+ if errors.Is(openErr, os.ErrNotExist) {
+ return nil
+ }
+
+ return openErr
+ }
+
+ defer func() { _ = file.Close() }()
+
+ entries, readErr := file.ReadDir(-1)
+ if readErr != nil {
+ return readErr
+ }
+
+ for _, entry := range entries {
+ name := path.Join(dir, entry.Name())
+ if entry.IsDir() {
+ err := walk(root, name)
+ if err != nil {
+ return err
+ }
+
+ continue
+ }
+
+ if strings.HasSuffix(name, ".lock") {
+ continue
+ }
+
+ if _, ok := seen[name]; ok {
+ continue
+ }
+
+ seen[name] = struct{}{}
+ names = append(names, name)
+ }
+
+ return nil
+ }
+
+ err = walk(store.commonRoot, "refs")
+ if err != nil {
+ return nil, err
+ }
+
+ err = walk(store.gitRoot, "refs")
+ if err != nil {
+ return nil, err
+ }
+
+ return names, nil
+}
diff --git a/ref/store/files/read_loose.go b/ref/store/files/read_loose.go
new file mode 100644
index 00000000..fbbdb109
--- /dev/null
+++ b/ref/store/files/read_loose.go
@@ -0,0 +1,48 @@
+package files
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func (store *Store) readLooseRef(name string) (ref.Ref, error) { //nolint:ireturn
+ refPath := store.loosePath(name)
+
+ data, err := store.rootFor(refPath.root).ReadFile(refPath.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, refstore.ErrReferenceNotFound
+ }
+
+ return nil, err
+ }
+
+ line := strings.TrimRightFunc(string(data), isRefWhitespace)
+ if strings.HasPrefix(line, "ref:") {
+ target := strings.TrimLeftFunc(line[len("ref:"):], isRefWhitespace)
+ if target == "" {
+ return nil, brokenRefError{name: name, err: fmt.Errorf("empty symbolic target")}
+ }
+
+ return ref.Symbolic{
+ RefName: name,
+ Target: target,
+ }, nil
+ }
+
+ id, err := objectid.ParseHex(store.algo, line)
+ if err != nil {
+ return nil, brokenRefError{name: name, err: err}
+ }
+
+ return ref.Detached{
+ RefName: name,
+ ID: id,
+ }, nil
+}
diff --git a/ref/store/files/read_resolve.go b/ref/store/files/read_resolve.go
new file mode 100644
index 00000000..bba6c7e7
--- /dev/null
+++ b/ref/store/files/read_resolve.go
@@ -0,0 +1,41 @@
+package files
+
+import (
+ "errors"
+
+ "codeberg.org/lindenii/furgit/ref"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// Resolve resolves one reference name from the files store visible namespace.
+func (store *Store) Resolve(name string) (ref.Ref, error) { //nolint:ireturn
+ if name == "" {
+ return nil, refstore.ErrReferenceNotFound
+ }
+
+ resolved, err := store.readLooseRef(name)
+ if err == nil {
+ return resolved, nil
+ }
+
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ refPath := store.loosePath(name)
+
+ info, statErr := store.rootFor(refPath.root).Stat(refPath.path)
+ if statErr != nil || !info.IsDir() {
+ return nil, err
+ }
+ }
+
+ packed, packedErr := store.readPackedRefs()
+ if packedErr != nil {
+ return nil, packedErr
+ }
+
+ detached, ok := packed.byName[name]
+ if !ok {
+ return nil, refstore.ErrReferenceNotFound
+ }
+
+ return detached, nil
+}
diff --git a/ref/store/files/read_resolve_fully.go b/ref/store/files/read_resolve_fully.go
new file mode 100644
index 00000000..de58eb6d
--- /dev/null
+++ b/ref/store/files/read_resolve_fully.go
@@ -0,0 +1,42 @@
+package files
+
+import (
+ "fmt"
+ "strings"
+
+ "codeberg.org/lindenii/furgit/ref"
+)
+
+// ResolveToDetached resolves symbolic references through the visible files store
+// namespace until one detached reference is reached.
+func (store *Store) ResolveToDetached(name string) (ref.Detached, error) {
+ cur := name
+ seen := make(map[string]struct{})
+
+ for {
+ if _, ok := seen[cur]; ok {
+ return ref.Detached{}, fmt.Errorf("refstore/files: symbolic reference cycle at %q", cur)
+ }
+
+ seen[cur] = struct{}{}
+
+ resolved, err := store.Resolve(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/files: symbolic reference %q has empty target", resolved.Name())
+ }
+
+ cur = target
+ default:
+ return ref.Detached{}, fmt.Errorf("refstore/files: unsupported reference type %T", resolved)
+ }
+ }
+}
diff --git a/ref/store/files/resolve_list_test.go b/ref/store/files/resolve_list_test.go
new file mode 100644
index 00000000..e25a53f4
--- /dev/null
+++ b/ref/store/files/resolve_list_test.go
@@ -0,0 +1,269 @@
+package files_test
+
+import (
+ "slices"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref"
+)
+
+func TestFilesResolveAndListOverlay(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, packedID := testRepo.MakeCommit(t, "packed base")
+ _, _, looseID := testRepo.MakeCommit(t, "loose override")
+ testRepo.UpdateRef(t, "refs/heads/main", packedID)
+ testRepo.UpdateRef(t, "refs/tags/v1", packedID)
+ testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.UpdateRef(t, "refs/heads/main", looseID)
+ testRepo.UpdateRef(t, "refs/heads/dev", looseID)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ resolvedMain, err := store.Resolve("refs/heads/main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ mainDet, ok := resolvedMain.(ref.Detached)
+ if !ok {
+ t.Fatalf("Resolve(main) type = %T, want ref.Detached", resolvedMain)
+ }
+
+ if mainDet.ID != looseID {
+ t.Fatalf("Resolve(main) id = %s, want %s", mainDet.ID, looseID)
+ }
+
+ resolvedHead, err := store.Resolve("HEAD")
+ if err != nil {
+ t.Fatalf("Resolve(HEAD): %v", err)
+ }
+
+ headSym, ok := resolvedHead.(ref.Symbolic)
+ if !ok {
+ t.Fatalf("Resolve(HEAD) type = %T, want ref.Symbolic", resolvedHead)
+ }
+
+ if headSym.Target != "refs/heads/main" {
+ t.Fatalf("Resolve(HEAD) target = %q, want %q", headSym.Target, "refs/heads/main")
+ }
+
+ fullHead, err := store.ResolveToDetached("HEAD")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(HEAD): %v", err)
+ }
+
+ if fullHead.ID != looseID {
+ t.Fatalf("ResolveToDetached(HEAD) = %s, want %s", fullHead.ID, looseID)
+ }
+
+ allRefs, err := store.List("")
+ if err != nil {
+ t.Fatalf("List(\"\"): %v", err)
+ }
+
+ names := make([]string, 0, len(allRefs))
+ for _, entry := range allRefs {
+ names = append(names, entry.Name())
+ }
+
+ slices.Sort(names)
+
+ want := []string{"HEAD", "refs/heads/dev", "refs/heads/main", "refs/tags/v1"}
+ if !slices.Equal(names, want) {
+ t.Fatalf("List(\"\") names = %v, want %v", names, want)
+ }
+ })
+}
+
+func TestFilesLooseRefParsingMatchesGit(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, RefFormat: "files"})
+ oid := testRepo.HashObject(t, "blob", []byte("payload\n"))
+
+ testRepo.WriteFileAll(t, ".git/refs/heads/no-lf", []byte(oid.String()), 0o755, 0o644)
+ testRepo.WriteFileAll(t, ".git/refs/heads/trailing-ws", []byte(oid.String()+" "), 0o755, 0o644)
+ testRepo.WriteFileAll(t, ".git/refs/heads/leading-ws", []byte(" "+oid.String()+"\n"), 0o755, 0o644)
+ testRepo.WriteFileAll(t, ".git/refs/heads/sym-trailing", []byte("ref: refs/heads/main "), 0o755, 0o644)
+ testRepo.WriteFileAll(t, ".git/refs/heads/sym-leading", []byte(" ref: refs/heads/main\n"), 0o755, 0o644)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ got, err := store.ResolveToDetached("refs/heads/no-lf")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(no-lf): %v", err)
+ }
+
+ if got.ID != oid {
+ t.Fatalf("ResolveToDetached(no-lf) = %s, want %s", got.ID, oid)
+ }
+
+ got, err = store.ResolveToDetached("refs/heads/trailing-ws")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(trailing-ws): %v", err)
+ }
+
+ if got.ID != oid {
+ t.Fatalf("ResolveToDetached(trailing-ws) = %s, want %s", got.ID, oid)
+ }
+
+ _, err = store.Resolve("refs/heads/leading-ws")
+ if err == nil {
+ t.Fatal("Resolve(leading-ws) unexpectedly succeeded")
+ }
+
+ resolved, err := store.Resolve("refs/heads/sym-trailing")
+ if err != nil {
+ t.Fatalf("Resolve(sym-trailing): %v", err)
+ }
+
+ sym, ok := resolved.(ref.Symbolic)
+ if !ok {
+ t.Fatalf("Resolve(sym-trailing) type = %T, want ref.Symbolic", resolved)
+ }
+
+ if sym.Target != "refs/heads/main" {
+ t.Fatalf("Resolve(sym-trailing) target = %q, want %q", sym.Target, "refs/heads/main")
+ }
+
+ _, err = store.Resolve("refs/heads/sym-leading")
+ if err == nil {
+ t.Fatal("Resolve(sym-leading) unexpectedly succeeded")
+ }
+ })
+}
+
+func TestFilesRejectMalformedPackedRefs(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true, RefFormat: "files"})
+ _, _, commitID := testRepo.MakeCommit(t, "packed")
+ testRepo.UpdateRef(t, "refs/heads/main", commitID)
+ testRepo.PackRefs(t, "--all", "--prune")
+
+ hex := commitID.String()
+
+ cases := []struct {
+ name string
+ content string
+ }{
+ {
+ name: "unterminated line",
+ content: "# pack-refs with: peeled fully-peeled sorted\n" + hex + " refs/heads/main",
+ },
+ {
+ name: "junk line",
+ content: "# pack-refs with: peeled fully-peeled sorted\nbogus content\n",
+ },
+ {
+ name: "short oid",
+ content: "# pack-refs with: peeled fully-peeled sorted\n" + hex[:7] + " refs/heads/main\n",
+ },
+ {
+ name: "trailing garbage after oid",
+ content: "# pack-refs with: peeled fully-peeled sorted\n" + hex + "xrefs/heads/main\n",
+ },
+ {
+ name: "malformed peeled line",
+ content: "# pack-refs with: peeled fully-peeled sorted\n" + hex + " refs/tags/v1\n^" + hex + " garbage\n",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ testRepo.WriteFile(t, "packed-refs", []byte(tc.content), 0o644)
+ store := openFilesStore(t, testRepo, algo)
+
+ _, err := store.List("")
+ if err == nil {
+ t.Fatal("List unexpectedly succeeded")
+ }
+ })
+ }
+ })
+}
+
+func TestFilesPackedRefsReadSemanticsMatchGit(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ t.Run("stale packed entry is still readable", func(t *testing.T) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, RefFormat: "files"})
+ testRepo.Run(t, "commit", "--allow-empty", "-m", "one")
+
+ oneID, err := objectid.ParseHex(algo, testRepo.Run(t, "rev-parse", "HEAD"))
+ if err != nil {
+ t.Fatalf("ParseHex(one): %v", err)
+ }
+
+ testRepo.Run(t, "tag", "-a", "v1.0", "-m", "v1.0", "HEAD")
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.Run(t, "checkout", "--orphan", "another")
+ testRepo.Run(t, "commit", "--allow-empty", "-m", "two")
+ testRepo.Run(t, "checkout", "-B", "main")
+ testRepo.Run(t, "branch", "-D", "another")
+ testRepo.Run(t, "reflog", "expire", "--expire=now", "--all")
+ testRepo.Run(t, "prune")
+
+ store := openFilesStore(t, testRepo, algo)
+
+ got, err := store.ResolveToDetached("refs/heads/main")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main): %v", err)
+ }
+
+ if got.ID == oneID {
+ t.Fatalf("ResolveToDetached(main) unexpectedly returned stale packed id %s", oneID)
+ }
+
+ tagRef, err := store.Resolve("refs/tags/v1.0")
+ if err != nil {
+ t.Fatalf("Resolve(tag): %v", err)
+ }
+
+ tagDet, ok := tagRef.(ref.Detached)
+ if !ok {
+ t.Fatalf("Resolve(tag) type = %T, want ref.Detached", tagRef)
+ }
+
+ if tagDet.ID.Size() == 0 {
+ t.Fatal("Resolve(tag) returned zero object id")
+ }
+ })
+
+ t.Run("exact unicode packed ref remains enumerable", func(t *testing.T) {
+ t.Parallel()
+
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, RefFormat: "files"})
+ _, _, commitID := testRepo.MakeCommit(t, "unicode")
+ testRepo.UpdateRef(t, "refs/heads/\ue43f", commitID)
+ testRepo.UpdateRef(t, "refs/heads/z", commitID)
+ testRepo.PackRefs(t, "--all", "--prune")
+
+ store := openFilesStore(t, testRepo, algo)
+
+ listed, err := store.List("refs/heads/z")
+ if err != nil {
+ t.Fatalf("List(refs/heads/z): %v", err)
+ }
+
+ if len(listed) != 1 {
+ t.Fatalf("List(refs/heads/z) len = %d, want 1", len(listed))
+ }
+
+ if listed[0].Name() != "refs/heads/z" {
+ t.Fatalf("List(refs/heads/z)[0] = %q, want %q", listed[0].Name(), "refs/heads/z")
+ }
+ })
+ })
+}
diff --git a/ref/store/files/root_for.go b/ref/store/files/root_for.go
new file mode 100644
index 00000000..cb968ad9
--- /dev/null
+++ b/ref/store/files/root_for.go
@@ -0,0 +1,13 @@
+package files
+
+import (
+ "os"
+)
+
+func (store *Store) rootFor(kind rootKind) *os.Root {
+ if kind == rootCommon {
+ return store.commonRoot
+ }
+
+ return store.gitRoot
+}
diff --git a/ref/store/files/root_kind.go b/ref/store/files/root_kind.go
new file mode 100644
index 00000000..d0ae8cf1
--- /dev/null
+++ b/ref/store/files/root_kind.go
@@ -0,0 +1,8 @@
+package files
+
+type rootKind uint8
+
+const (
+ rootGit rootKind = iota
+ rootCommon
+)
diff --git a/ref/store/files/root_loose_path.go b/ref/store/files/root_loose_path.go
new file mode 100644
index 00000000..a78d9bf3
--- /dev/null
+++ b/ref/store/files/root_loose_path.go
@@ -0,0 +1,24 @@
+package files
+
+import (
+ "path"
+
+ "codeberg.org/lindenii/furgit/ref/refname"
+)
+
+func (store *Store) loosePath(name string) refPath {
+ parsed := refname.ParseWorktree(name)
+ switch parsed.Type {
+ case refname.WorktreeCurrent:
+ return refPath{root: rootGit, path: parsed.BareRefName}
+ case refname.WorktreeMain, refname.WorktreeShared:
+ return refPath{root: rootCommon, path: parsed.BareRefName}
+ case refname.WorktreeOther:
+ return refPath{
+ root: rootCommon,
+ path: path.Join("worktrees", parsed.WorktreeName, parsed.BareRefName),
+ }
+ default:
+ return refPath{root: rootCommon, path: name}
+ }
+}
diff --git a/ref/store/files/root_open_common.go b/ref/store/files/root_open_common.go
new file mode 100644
index 00000000..cac98cbc
--- /dev/null
+++ b/ref/store/files/root_open_common.go
@@ -0,0 +1,31 @@
+package files
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func openCommonRoot(gitRoot *os.Root) (*os.Root, error) {
+ content, err := gitRoot.ReadFile("commondir")
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return gitRoot.OpenRoot(".")
+ }
+
+ return nil, err
+ }
+
+ commonDir := strings.TrimSpace(string(content))
+ if commonDir == "" {
+ return nil, os.ErrNotExist
+ }
+
+ if filepath.IsAbs(commonDir) {
+ return os.OpenRoot(commonDir)
+ }
+
+ // This is okay because that's how Git defines it anyway.
+ return os.OpenRoot(filepath.Join(gitRoot.Name(), commonDir))
+}
diff --git a/ref/store/files/store.go b/ref/store/files/store.go
new file mode 100644
index 00000000..e0cced65
--- /dev/null
+++ b/ref/store/files/store.go
@@ -0,0 +1,32 @@
+// Package files provides one Git files ref store with loose-over-packed reads
+// and transaction-coordinated updates.
+package files
+
+import (
+ "math/rand"
+ "os"
+ "time"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+// Store reads and writes one Git files ref namespace rooted at one repository
+// gitdir plus its commondir.
+//
+// Store borrows gitRoot and owns commonRoot. Close releases only resources
+// opened by the store itself.
+type Store struct {
+ gitRoot *os.Root
+ commonRoot *os.Root
+ algo objectid.Algorithm
+ lockRand *rand.Rand
+
+ packedRefsTimeout time.Duration
+}
+
+var (
+ _ refstore.ReadingStore = (*Store)(nil)
+ _ refstore.TransactionalStore = (*Store)(nil)
+ _ refstore.BatchStore = (*Store)(nil)
+)
diff --git a/ref/store/files/transaction.go b/ref/store/files/transaction.go
new file mode 100644
index 00000000..26d6613d
--- /dev/null
+++ b/ref/store/files/transaction.go
@@ -0,0 +1,12 @@
+package files
+
+import (
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+type Transaction struct {
+ store *Store
+ ops []queuedUpdate
+}
+
+var _ refstore.Transaction = (*Transaction)(nil)
diff --git a/ref/store/files/transaction_abort.go b/ref/store/files/transaction_abort.go
new file mode 100644
index 00000000..cb82e4bf
--- /dev/null
+++ b/ref/store/files/transaction_abort.go
@@ -0,0 +1,3 @@
+package files
+
+func (tx *Transaction) Abort() error { return nil }
diff --git a/ref/store/files/transaction_begin.go b/ref/store/files/transaction_begin.go
new file mode 100644
index 00000000..1eca2375
--- /dev/null
+++ b/ref/store/files/transaction_begin.go
@@ -0,0 +1,13 @@
+package files
+
+import "codeberg.org/lindenii/furgit/ref/store"
+
+// BeginTransaction creates one new files transaction.
+//
+//nolint:ireturn
+func (store *Store) BeginTransaction() (refstore.Transaction, error) {
+ return &Transaction{
+ store: store,
+ ops: make([]queuedUpdate, 0, 8),
+ }, nil
+}
diff --git a/ref/store/files/transaction_commit.go b/ref/store/files/transaction_commit.go
new file mode 100644
index 00000000..76bcb195
--- /dev/null
+++ b/ref/store/files/transaction_commit.go
@@ -0,0 +1,12 @@
+package files
+
+func (tx *Transaction) Commit() error {
+ executor := &refUpdateExecutor{store: tx.store}
+
+ prepared, err := executor.prepareUpdates(tx.ops)
+ if err != nil {
+ return err
+ }
+
+ return executor.commitPreparedUpdates(prepared)
+}
diff --git a/ref/store/files/transaction_dirs_test.go b/ref/store/files/transaction_dirs_test.go
new file mode 100644
index 00000000..c010ae69
--- /dev/null
+++ b/ref/store/files/transaction_dirs_test.go
@@ -0,0 +1,220 @@
+package files_test
+
+import (
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+func TestFilesTransactionEmptyDirectoriesDoNotBlock(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, oldID := testRepo.MakeCommit(t, "old")
+ _, _, newID := testRepo.MakeCommit(t, "new")
+
+ testRepo.UpdateRef(t, "refs/e-verify/foo", oldID)
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.WriteFileAll(t, "refs/e-verify/foo/bar/baz/.keep", []byte{}, 0o755, 0o644)
+ testRepo.Remove(t, "refs/e-verify/foo/bar/baz/.keep")
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(verify): %v", err)
+ }
+
+ err = tx.Verify("refs/e-verify/foo", oldID)
+ if err != nil {
+ t.Fatalf("Verify with empty directories: %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(verify with empty directories): %v", err)
+ }
+
+ testRepo.UpdateRef(t, "refs/e-update/foo", oldID)
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.WriteFileAll(t, "refs/e-update/foo/bar/baz/.keep", []byte{}, 0o755, 0o644)
+ testRepo.Remove(t, "refs/e-update/foo/bar/baz/.keep")
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(update): %v", err)
+ }
+
+ err = tx.Update("refs/e-update/foo", newID, oldID)
+ if err != nil {
+ t.Fatalf("Update with empty directories: %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(update with empty directories): %v", err)
+ }
+
+ got, err := store.ResolveToDetached("refs/e-update/foo")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(updated foo): %v", err)
+ }
+
+ if got.ID != newID {
+ t.Fatalf("updated foo = %s, want %s", got.ID, newID)
+ }
+
+ testRepo.WriteFileAll(t, "refs/e-create/foo/bar/baz/.keep", []byte{}, 0o755, 0o644)
+ testRepo.Remove(t, "refs/e-create/foo/bar/baz/.keep")
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(create): %v", err)
+ }
+
+ err = tx.Create("refs/e-create/foo", oldID)
+ if err != nil {
+ t.Fatalf("Create with empty directories: %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(create with empty directories): %v", err)
+ }
+
+ got, err = store.ResolveToDetached("refs/e-create/foo")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(created foo): %v", err)
+ }
+
+ if got.ID != oldID {
+ t.Fatalf("created foo = %s, want %s", got.ID, oldID)
+ }
+
+ testRepo.UpdateRef(t, "refs/e-delete/foo", oldID)
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.WriteFileAll(t, "refs/e-delete/foo/bar/baz/.keep", []byte{}, 0o755, 0o644)
+ testRepo.Remove(t, "refs/e-delete/foo/bar/baz/.keep")
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(delete): %v", err)
+ }
+
+ err = tx.Delete("refs/e-delete/foo", oldID)
+ if err != nil {
+ t.Fatalf("Delete with empty directories: %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(delete with empty directories): %v", err)
+ }
+ })
+}
+
+func TestFilesTransactionNonEmptyDirectoryAndBrokenRefBlockCreate(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+ store := openFilesStore(t, testRepo, algo)
+
+ testRepo.WriteFileAll(t, "refs/ne-create/foo/bar/baz.lock", []byte(""), 0o755, 0o644)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(non-empty dir): %v", err)
+ }
+
+ err = tx.Create("refs/ne-create/foo", commitID)
+ if err != nil {
+ t.Fatalf("Create(non-empty dir) queue: %v", err)
+ }
+
+ err = tx.Commit()
+ if err == nil {
+ t.Fatal("Commit(non-empty dir) unexpectedly succeeded")
+ }
+
+ testRepo.WriteFileAll(t, "refs/broken/foo", []byte("gobbledigook\n"), 0o755, 0o644)
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(broken ref): %v", err)
+ }
+
+ err = tx.Create("refs/broken/foo", commitID)
+ if err != nil {
+ t.Fatalf("Create(broken ref) queue: %v", err)
+ }
+
+ err = tx.Commit()
+ if err == nil {
+ t.Fatal("Commit(broken ref) unexpectedly succeeded")
+ }
+ })
+}
+
+func TestFilesTransactionIndirectCreateMatchesGit(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ t.Run("non-empty directory blocks", func(t *testing.T) {
+ t.Parallel()
+
+ repo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, RefFormat: "files"})
+ _, _, innerID := repo.MakeCommit(t, "inner")
+ prefix := "refs/ne-indirect-create"
+
+ repo.SymbolicRef(t, prefix+"/symref", prefix+"/foo")
+ repo.WriteFileAll(t, ".git/"+prefix+"/foo/bar/baz.lock", []byte{}, 0o755, 0o644)
+ store := openFilesStore(t, repo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(non-empty): %v", err)
+ }
+
+ err = tx.Create(prefix+"/symref", innerID)
+ if err != nil {
+ t.Fatalf("Create(non-empty) queue: %v", err)
+ }
+
+ err = tx.Commit()
+ if err == nil {
+ t.Fatal("Commit(non-empty) unexpectedly succeeded")
+ }
+ })
+
+ t.Run("broken referent blocks", func(t *testing.T) {
+ t.Parallel()
+
+ repo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, RefFormat: "files"})
+ _, _, commitID := repo.MakeCommit(t, "broken")
+ prefix := "refs/broken-indirect-create"
+
+ repo.SymbolicRef(t, prefix+"/symref", prefix+"/foo")
+ repo.WriteFileAll(t, ".git/"+prefix+"/foo", []byte("gobbledigook\n"), 0o755, 0o644)
+ store := openFilesStore(t, repo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(broken): %v", err)
+ }
+
+ err = tx.Create(prefix+"/symref", commitID)
+ if err != nil {
+ t.Fatalf("Create(broken) queue: %v", err)
+ }
+
+ err = tx.Commit()
+ if err == nil {
+ t.Fatal("Commit(broken) unexpectedly succeeded")
+ }
+ })
+ })
+}
diff --git a/ref/store/files/transaction_names_test.go b/ref/store/files/transaction_names_test.go
new file mode 100644
index 00000000..f23294e5
--- /dev/null
+++ b/ref/store/files/transaction_names_test.go
@@ -0,0 +1,188 @@
+package files_test
+
+import (
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func TestFilesTransactionValidateUpdateNames(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, commitID := testRepo.MakeCommit(t, "base")
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tests := []struct {
+ name string
+ queue func(refstore.Transaction) error
+ wantErr bool
+ }{
+ {
+ name: "create refs/heads/main",
+ queue: func(tx refstore.Transaction) error {
+ return tx.Create("refs/heads/main", commitID)
+ },
+ },
+ {
+ name: "create foo/bar",
+ queue: func(tx refstore.Transaction) error {
+ return tx.Create("foo/bar", commitID)
+ },
+ },
+ {
+ name: "create FETCH_HEAD",
+ queue: func(tx refstore.Transaction) error {
+ return tx.Create("FETCH_HEAD", commitID)
+ },
+ wantErr: true,
+ },
+ {
+ name: "create MERGE_HEAD",
+ queue: func(tx refstore.Transaction) error {
+ return tx.Create("MERGE_HEAD", commitID)
+ },
+ wantErr: true,
+ },
+ {
+ name: "create bad refname",
+ queue: func(tx refstore.Transaction) error {
+ return tx.Create("refs/heads/.bad", commitID)
+ },
+ wantErr: true,
+ },
+ {
+ name: "verify unsafe delete-style name",
+ queue: func(tx refstore.Transaction) error {
+ return tx.Verify("foo/bar", commitID)
+ },
+ wantErr: true,
+ },
+ {
+ name: "verify pseudoref-style name",
+ queue: func(tx refstore.Transaction) error {
+ return tx.Verify("PSEUDOREF", commitID)
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tt.queue(tx)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("queue err=%v, wantErr=%v", err, tt.wantErr)
+ }
+
+ _ = tx.Abort()
+ })
+ }
+ })
+}
+
+func TestFilesTransactionSymbolicTargetRules(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, mainID := testRepo.MakeCommit(t, "main")
+ testRepo.UpdateRef(t, "refs/heads/main", mainID)
+ testRepo.UpdateRef(t, "ORIG_HEAD", mainID)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tests := []struct {
+ name string
+ queue func(refstore.Transaction) error
+ wantErr bool
+ }{
+ {
+ name: "head requires branch target",
+ queue: func(tx refstore.Transaction) error {
+ return tx.CreateSymbolic("HEAD", "foo")
+ },
+ wantErr: true,
+ },
+ {
+ name: "head accepts refs/heads target",
+ queue: func(tx refstore.Transaction) error {
+ return tx.CreateSymbolic("HEAD", "refs/heads/main")
+ },
+ },
+ {
+ name: "non-head allows top-level target",
+ queue: func(tx refstore.Transaction) error {
+ return tx.CreateSymbolic("refs/heads/top-level", "ORIG_HEAD")
+ },
+ },
+ {
+ name: "non-head rejects invalid target",
+ queue: func(tx refstore.Transaction) error {
+ return tx.CreateSymbolic("refs/heads/invalid", "foo..bar")
+ },
+ wantErr: true,
+ },
+ {
+ name: "non-head allows worktree target",
+ queue: func(tx refstore.Transaction) error {
+ return tx.CreateSymbolic("refs/heads/worktree-target", "worktrees/wt1/HEAD")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tt.queue(tx)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("queue err=%v, wantErr=%v", err, tt.wantErr)
+ }
+
+ _ = tx.Abort()
+ })
+ }
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(final symbolic): %v", err)
+ }
+
+ err = tx.CreateSymbolic("refs/heads/top-level", "ORIG_HEAD")
+ if err != nil {
+ t.Fatalf("CreateSymbolic(top-level): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(CreateSymbolic top-level): %v", err)
+ }
+
+ got, err := store.Resolve("refs/heads/top-level")
+ if err != nil {
+ t.Fatalf("Resolve(top-level): %v", err)
+ }
+
+ sym, ok := got.(ref.Symbolic)
+ if !ok {
+ t.Fatalf("Resolve(top-level) type = %T, want ref.Symbolic", got)
+ }
+
+ if sym.Target != "ORIG_HEAD" {
+ t.Fatalf("top-level target = %q, want %q", sym.Target, "ORIG_HEAD")
+ }
+ })
+}
diff --git a/ref/store/files/transaction_pseudoref_test.go b/ref/store/files/transaction_pseudoref_test.go
new file mode 100644
index 00000000..53fb26c0
--- /dev/null
+++ b/ref/store/files/transaction_pseudoref_test.go
@@ -0,0 +1,106 @@
+package files_test
+
+import (
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func TestFilesTransactionPseudorefLifecycle(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, aID := testRepo.MakeCommit(t, "A")
+ _, _, bID := testRepo.MakeCommit(t, "B")
+ _, _, cID := testRepo.MakeCommit(t, "C")
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(create): %v", err)
+ }
+
+ err = tx.Create("PSEUDOREF", aID)
+ if err != nil {
+ t.Fatalf("Create(PSEUDOREF): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(create PSEUDOREF): %v", err)
+ }
+
+ got, err := store.ResolveToDetached("PSEUDOREF")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(PSEUDOREF): %v", err)
+ }
+
+ if got.ID != aID {
+ t.Fatalf("PSEUDOREF after create = %s, want %s", got.ID, aID)
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(update): %v", err)
+ }
+
+ err = tx.Update("PSEUDOREF", bID, aID)
+ if err != nil {
+ t.Fatalf("Update(PSEUDOREF): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(update PSEUDOREF): %v", err)
+ }
+
+ got, err = store.ResolveToDetached("PSEUDOREF")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(PSEUDOREF) after update: %v", err)
+ }
+
+ if got.ID != bID {
+ t.Fatalf("PSEUDOREF after update = %s, want %s", got.ID, bID)
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(stale update): %v", err)
+ }
+
+ err = tx.Update("PSEUDOREF", cID, aID)
+ if err != nil {
+ t.Fatalf("queue stale update: %v", err)
+ }
+
+ err = tx.Commit()
+ if err == nil {
+ t.Fatal("stale pseudoref update unexpectedly succeeded")
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(delete): %v", err)
+ }
+
+ err = tx.Delete("PSEUDOREF", bID)
+ if err != nil {
+ t.Fatalf("Delete(PSEUDOREF): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(delete PSEUDOREF): %v", err)
+ }
+
+ _, err = store.Resolve("PSEUDOREF")
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ t.Fatalf("Resolve(PSEUDOREF after delete) err=%v", err)
+ }
+ })
+}
diff --git a/ref/store/files/transaction_queue.go b/ref/store/files/transaction_queue.go
new file mode 100644
index 00000000..aa2004c3
--- /dev/null
+++ b/ref/store/files/transaction_queue.go
@@ -0,0 +1,12 @@
+package files
+
+func (tx *Transaction) queue(op queuedUpdate) error {
+ err := (&refUpdateExecutor{store: tx.store}).validateQueuedUpdate(op)
+ if err != nil {
+ return err
+ }
+
+ tx.ops = append(tx.ops, op)
+
+ return nil
+}
diff --git a/ref/store/files/transaction_queue_ops.go b/ref/store/files/transaction_queue_ops.go
new file mode 100644
index 00000000..047518c4
--- /dev/null
+++ b/ref/store/files/transaction_queue_ops.go
@@ -0,0 +1,35 @@
+package files
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+func (tx *Transaction) Create(name string, newID objectid.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateCreate, newID: newID})
+}
+
+func (tx *Transaction) Update(name string, newID, oldID objectid.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateReplace, newID: newID, oldID: oldID})
+}
+
+func (tx *Transaction) Delete(name string, oldID objectid.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateDelete, oldID: oldID})
+}
+
+func (tx *Transaction) Verify(name string, oldID objectid.ObjectID) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateVerify, oldID: oldID})
+}
+
+func (tx *Transaction) CreateSymbolic(name, newTarget string) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateCreateSymbolic, newTarget: newTarget})
+}
+
+func (tx *Transaction) UpdateSymbolic(name, newTarget, oldTarget string) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateReplaceSymbolic, newTarget: newTarget, oldTarget: oldTarget})
+}
+
+func (tx *Transaction) DeleteSymbolic(name, oldTarget string) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateDeleteSymbolic, oldTarget: oldTarget})
+}
+
+func (tx *Transaction) VerifySymbolic(name, oldTarget string) error {
+ return tx.queue(queuedUpdate{name: name, kind: updateVerifySymbolic, oldTarget: oldTarget})
+}
diff --git a/ref/store/files/transaction_symbolic_test.go b/ref/store/files/transaction_symbolic_test.go
new file mode 100644
index 00000000..cc5a590b
--- /dev/null
+++ b/ref/store/files/transaction_symbolic_test.go
@@ -0,0 +1,154 @@
+package files_test
+
+import (
+ "errors"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func TestFilesTransactionDirectSymbolicDeletes(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, mainID := testRepo.MakeCommit(t, "main")
+ testRepo.UpdateRef(t, "refs/heads/main", mainID)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(create symref): %v", err)
+ }
+
+ err = tx.CreateSymbolic("SYMREF", "refs/heads/main")
+ if err != nil {
+ t.Fatalf("CreateSymbolic(SYMREF): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(CreateSymbolic SYMREF): %v", err)
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(delete symref): %v", err)
+ }
+
+ err = tx.DeleteSymbolic("SYMREF", "refs/heads/main")
+ if err != nil {
+ t.Fatalf("DeleteSymbolic(SYMREF): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(DeleteSymbolic SYMREF): %v", err)
+ }
+
+ _, err = store.Resolve("SYMREF")
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ t.Fatalf("Resolve(SYMREF after delete) err=%v", err)
+ }
+
+ got, err := store.ResolveToDetached("refs/heads/main")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main): %v", err)
+ }
+
+ if got.ID != mainID {
+ t.Fatalf("main after DeleteSymbolic = %s, want %s", got.ID, mainID)
+ }
+ })
+}
+
+func TestFilesTransactionSelfAndDanglingSymrefs(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, mainID := testRepo.MakeCommit(t, "main")
+ testRepo.UpdateRef(t, "refs/heads/main", mainID)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(create self): %v", err)
+ }
+
+ err = tx.CreateSymbolic("refs/heads/self", "refs/heads/self")
+ if err != nil {
+ t.Fatalf("CreateSymbolic(self): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(CreateSymbolic self): %v", err)
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(delete logical self): %v", err)
+ }
+
+ err = tx.Delete("refs/heads/self", mainID)
+ if err == nil {
+ err = tx.Commit()
+ } else {
+ _ = tx.Abort()
+ }
+
+ if err == nil {
+ t.Fatal("Delete(self) unexpectedly succeeded")
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(delete symbolic self): %v", err)
+ }
+
+ err = tx.DeleteSymbolic("refs/heads/self", "refs/heads/self")
+ if err != nil {
+ t.Fatalf("DeleteSymbolic(self): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(DeleteSymbolic self): %v", err)
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(create dangling): %v", err)
+ }
+
+ err = tx.CreateSymbolic("refs/heads/dangling", "refs/heads/missing")
+ if err != nil {
+ t.Fatalf("CreateSymbolic(dangling): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(CreateSymbolic dangling): %v", err)
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(delete dangling): %v", err)
+ }
+
+ err = tx.DeleteSymbolic("refs/heads/dangling", "refs/heads/missing")
+ if err != nil {
+ t.Fatalf("DeleteSymbolic(dangling): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(DeleteSymbolic dangling): %v", err)
+ }
+ })
+}
diff --git a/ref/store/files/transaction_update_test.go b/ref/store/files/transaction_update_test.go
new file mode 100644
index 00000000..62879b32
--- /dev/null
+++ b/ref/store/files/transaction_update_test.go
@@ -0,0 +1,178 @@
+package files_test
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func TestFilesTransactionPackedUpdateCreatesLooseOverride(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, oldID := testRepo.MakeCommit(t, "old packed")
+ _, _, newID := testRepo.MakeCommit(t, "new loose")
+ testRepo.UpdateRef(t, "refs/heads/main", oldID)
+ testRepo.PackRefs(t, "--all", "--prune")
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Update("refs/heads/main", newID, oldID)
+ if err != nil {
+ t.Fatalf("Update queue: %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit: %v", err)
+ }
+
+ got, err := store.ResolveToDetached("refs/heads/main")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main): %v", err)
+ }
+
+ if got.ID != newID {
+ t.Fatalf("ResolveToDetached(main) = %s, want %s", got.ID, newID)
+ }
+
+ packedRefs := string(testRepo.ReadFile(t, "packed-refs"))
+ if !strings.Contains(packedRefs, oldID.String()+" refs/heads/main\n") {
+ t.Fatalf("packed-refs lost old packed main entry:\n%s", packedRefs)
+ }
+
+ looseMain := string(testRepo.ReadFile(t, "refs/heads/main"))
+ if strings.TrimSpace(looseMain) != newID.String() {
+ t.Fatalf("loose refs/heads/main = %q, want %q", strings.TrimSpace(looseMain), newID.String())
+ }
+ })
+}
+
+func TestFilesTransactionDeletesPackedAndLooseRefs(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, packedOnlyID := testRepo.MakeCommit(t, "packed only")
+ _, _, bothID := testRepo.MakeCommit(t, "both")
+ testRepo.UpdateRef(t, "refs/heads/packed", packedOnlyID)
+ testRepo.UpdateRef(t, "refs/heads/both", bothID)
+ testRepo.PackRefs(t, "--all", "--prune")
+ testRepo.UpdateRef(t, "refs/heads/both", bothID)
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction: %v", err)
+ }
+
+ err = tx.Delete("refs/heads/packed", packedOnlyID)
+ if err != nil {
+ t.Fatalf("Delete(packed): %v", err)
+ }
+
+ err = tx.Delete("refs/heads/both", bothID)
+ if err != nil {
+ t.Fatalf("Delete(both): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(delete): %v", err)
+ }
+
+ _, err = store.Resolve("refs/heads/packed")
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ t.Fatalf("Resolve(packed after delete) error = %v", err)
+ }
+
+ _, err = store.Resolve("refs/heads/both")
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ t.Fatalf("Resolve(both after delete) error = %v", err)
+ }
+
+ packedRefs := string(testRepo.ReadFile(t, "packed-refs"))
+ if strings.Contains(packedRefs, "refs/heads/packed\n") || strings.Contains(packedRefs, "refs/heads/both\n") {
+ t.Fatalf("packed-refs still contains deleted refs:\n%s", packedRefs)
+ }
+ })
+}
+
+func TestFilesTransactionDerefAndDirectSymbolic(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, Bare: true})
+ _, _, firstID := testRepo.MakeCommit(t, "first")
+ _, _, secondID := testRepo.MakeCommit(t, "second")
+ testRepo.UpdateRef(t, "refs/heads/main", firstID)
+ testRepo.SymbolicRef(t, "HEAD", "refs/heads/main")
+
+ store := openFilesStore(t, testRepo, algo)
+
+ tx, err := store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(update): %v", err)
+ }
+
+ err = tx.Update("HEAD", secondID, firstID)
+ if err != nil {
+ t.Fatalf("Update(HEAD): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(update HEAD): %v", err)
+ }
+
+ mainRef, err := store.ResolveToDetached("refs/heads/main")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main): %v", err)
+ }
+
+ if mainRef.ID != secondID {
+ t.Fatalf("main after Update(HEAD) = %s, want %s", mainRef.ID, secondID)
+ }
+
+ tx, err = store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(update symbolic): %v", err)
+ }
+
+ err = tx.UpdateSymbolic("HEAD", "refs/heads/next", "refs/heads/main")
+ if err != nil {
+ t.Fatalf("UpdateSymbolic(HEAD): %v", err)
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(update symbolic HEAD): %v", err)
+ }
+
+ headRef, err := store.Resolve("HEAD")
+ if err != nil {
+ t.Fatalf("Resolve(HEAD): %v", err)
+ }
+
+ headSym, ok := headRef.(ref.Symbolic)
+ if !ok {
+ t.Fatalf("Resolve(HEAD) type = %T, want ref.Symbolic", headRef)
+ }
+
+ if headSym.Target != "refs/heads/next" {
+ t.Fatalf("HEAD target = %q, want %q", headSym.Target, "refs/heads/next")
+ }
+ })
+}
diff --git a/ref/store/files/trim.go b/ref/store/files/trim.go
new file mode 100644
index 00000000..69a851dc
--- /dev/null
+++ b/ref/store/files/trim.go
@@ -0,0 +1,10 @@
+package files
+
+func isRefWhitespace(r rune) bool {
+ switch r {
+ case ' ', '\t', '\n', '\r', '\v', '\f':
+ return true
+ default:
+ return false
+ }
+}
diff --git a/ref/store/files/update_cleanup.go b/ref/store/files/update_cleanup.go
new file mode 100644
index 00000000..5df2d967
--- /dev/null
+++ b/ref/store/files/update_cleanup.go
@@ -0,0 +1,39 @@
+package files
+
+import (
+ "errors"
+ "os"
+ "slices"
+)
+
+func (executor *refUpdateExecutor) cleanupPreparedUpdates(prepared []preparedUpdate) error {
+ var firstErr error
+
+ lockNames := make([]string, 0, len(prepared)+1)
+ for _, item := range prepared {
+ lockNames = append(lockNames, updateTargetKey(item.target.loc))
+ }
+
+ lockNames = append(lockNames, updateTargetKey(refPath{root: rootCommon, path: "packed-refs"}))
+ slices.Sort(lockNames)
+ lockNames = slices.Compact(lockNames)
+
+ for _, lockKey := range lockNames {
+ lockPath := refPathFromKey(lockKey)
+ lockName := lockPath.path + ".lock"
+ root := executor.store.rootFor(lockPath.root)
+
+ err := root.Remove(lockName)
+ if err == nil || errors.Is(err, os.ErrNotExist) {
+ executor.tryRemoveEmptyParentPaths(lockPath.root, lockName)
+
+ continue
+ }
+
+ if firstErr == nil {
+ firstErr = err
+ }
+ }
+
+ return firstErr
+}
diff --git a/ref/store/files/update_cleanup_parents.go b/ref/store/files/update_cleanup_parents.go
new file mode 100644
index 00000000..c62681fa
--- /dev/null
+++ b/ref/store/files/update_cleanup_parents.go
@@ -0,0 +1,35 @@
+package files
+
+import (
+ "errors"
+ "os"
+ "path"
+)
+
+func (executor *refUpdateExecutor) tryRemoveEmptyParents(name string) {
+ loc := executor.store.loosePath(name)
+ executor.tryRemoveEmptyParentPaths(loc.root, loc.path)
+}
+
+func (executor *refUpdateExecutor) tryRemoveEmptyParentPaths(kind rootKind, name string) {
+ root := executor.store.rootFor(kind)
+ dir := path.Dir(name)
+
+ for dir != "." && dir != "/" {
+ err := root.Remove(dir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return
+ }
+
+ var pathErr *os.PathError
+ if errors.As(err, &pathErr) {
+ return
+ }
+
+ return
+ }
+
+ dir = path.Dir(dir)
+ }
+}
diff --git a/ref/store/files/update_commit.go b/ref/store/files/update_commit.go
new file mode 100644
index 00000000..3d39e990
--- /dev/null
+++ b/ref/store/files/update_commit.go
@@ -0,0 +1,25 @@
+package files
+
+func (executor *refUpdateExecutor) commitPreparedUpdates(prepared []preparedUpdate) (err error) {
+ defer func() {
+ _ = executor.cleanupPreparedUpdates(prepared)
+ }()
+
+ for _, item := range prepared {
+ if item.op.kind == updateDelete || item.op.kind == updateDeleteSymbolic || item.op.kind == updateVerify || item.op.kind == updateVerifySymbolic {
+ continue
+ }
+
+ err = executor.writePreparedLooseUpdate(item)
+ if err != nil {
+ return wrapUpdateError(item.op.name, err)
+ }
+ }
+
+ err = executor.applyPackedRefDeletes(prepared)
+ if err != nil {
+ return err
+ }
+
+ return executor.removeDeletedLooseRefs(prepared)
+}
diff --git a/ref/store/files/update_commit_delete.go b/ref/store/files/update_commit_delete.go
new file mode 100644
index 00000000..47a600fb
--- /dev/null
+++ b/ref/store/files/update_commit_delete.go
@@ -0,0 +1,25 @@
+package files
+
+import (
+ "errors"
+ "os"
+)
+
+func (executor *refUpdateExecutor) removeDeletedLooseRefs(prepared []preparedUpdate) error {
+ for _, item := range prepared {
+ switch item.op.kind {
+ case updateDelete, updateDeleteSymbolic:
+ if item.target.ref.isLoose {
+ err := executor.store.rootFor(item.target.loc.root).Remove(item.target.loc.path)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return wrapUpdateError(item.op.name, err)
+ }
+
+ executor.tryRemoveEmptyParents(item.target.name)
+ }
+ case updateCreate, updateReplace, updateVerify, updateCreateSymbolic, updateReplaceSymbolic, updateVerifySymbolic:
+ }
+ }
+
+ return nil
+}
diff --git a/ref/store/files/update_dir_tree.go b/ref/store/files/update_dir_tree.go
new file mode 100644
index 00000000..51fb5cfb
--- /dev/null
+++ b/ref/store/files/update_dir_tree.go
@@ -0,0 +1,59 @@
+package files
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path"
+)
+
+func (executor *refUpdateExecutor) removeEmptyDirTree(name refPath) error {
+ root := executor.store.rootFor(name.root)
+
+ info, err := root.Stat(name.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+
+ return err
+ }
+
+ if !info.IsDir() {
+ return nil
+ }
+
+ return executor.removeEmptyDirTreeRecursive(name)
+}
+
+func (executor *refUpdateExecutor) removeEmptyDirTreeRecursive(name refPath) error {
+ root := executor.store.rootFor(name.root)
+
+ dir, err := root.Open(name.path)
+ if err != nil {
+ return err
+ }
+
+ entries, err := dir.ReadDir(-1)
+ _ = dir.Close()
+
+ if err != nil {
+ return err
+ }
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ return fmt.Errorf("refstore/files: non-empty directory blocks reference %q", name.path)
+ }
+
+ err = executor.removeEmptyDirTreeRecursive(refPath{
+ root: name.root,
+ path: path.Join(name.path, entry.Name()),
+ })
+ if err != nil {
+ return err
+ }
+ }
+
+ return root.Remove(name.path)
+}
diff --git a/ref/store/files/update_direct_read.go b/ref/store/files/update_direct_read.go
new file mode 100644
index 00000000..03fb2e11
--- /dev/null
+++ b/ref/store/files/update_direct_read.go
@@ -0,0 +1,76 @@
+package files
+
+import (
+ "errors"
+ "fmt"
+
+ "codeberg.org/lindenii/furgit/ref"
+ "codeberg.org/lindenii/furgit/ref/refname"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func (executor *refUpdateExecutor) directRead(name string) (directRefState, error) {
+ loc := executor.store.loosePath(name)
+ hasPacked := false
+
+ if loc.root == rootCommon && refname.ParseWorktree(name).Type == refname.WorktreeShared {
+ packed, packedErr := executor.store.readPackedRefs()
+ if packedErr != nil {
+ return directRefState{}, packedErr
+ }
+
+ _, hasPacked = packed.byName[name]
+ }
+
+ loose, err := executor.store.readLooseRef(name)
+ if err == nil {
+ switch loose := loose.(type) {
+ case ref.Detached:
+ return directRefState{
+ kind: directDetached,
+ name: name,
+ id: loose.ID,
+ isLoose: true,
+ isPacked: hasPacked,
+ }, nil
+ case ref.Symbolic:
+ return directRefState{
+ kind: directSymbolic,
+ name: name,
+ target: loose.Target,
+ isLoose: true,
+ isPacked: hasPacked,
+ }, nil
+ default:
+ return directRefState{}, fmt.Errorf("refstore/files: unsupported reference type %T", loose)
+ }
+ }
+
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ info, statErr := executor.store.rootFor(loc.root).Stat(loc.path)
+ if statErr != nil || !info.IsDir() {
+ return directRefState{}, err
+ }
+ }
+
+ if hasPacked {
+ packed, packedErr := executor.store.readPackedRefs()
+ if packedErr != nil {
+ return directRefState{}, packedErr
+ }
+
+ detached := packed.byName[name]
+
+ return directRefState{
+ kind: directDetached,
+ name: name,
+ id: detached.ID,
+ isPacked: true,
+ }, nil
+ }
+
+ return directRefState{
+ kind: directMissing,
+ name: name,
+ }, nil
+}
diff --git a/ref/store/files/update_direct_ref.go b/ref/store/files/update_direct_ref.go
new file mode 100644
index 00000000..3b429be0
--- /dev/null
+++ b/ref/store/files/update_direct_ref.go
@@ -0,0 +1,20 @@
+package files
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+type directRefKind uint8
+
+const (
+ directMissing directRefKind = iota
+ directDetached
+ directSymbolic
+)
+
+type directRefState struct {
+ kind directRefKind
+ name string
+ id objectid.ObjectID
+ target string
+ isLoose bool
+ isPacked bool
+}
diff --git a/ref/store/files/update_error.go b/ref/store/files/update_error.go
new file mode 100644
index 00000000..d8841d44
--- /dev/null
+++ b/ref/store/files/update_error.go
@@ -0,0 +1,28 @@
+package files
+
+import "fmt"
+
+type updateContextError struct {
+ name string
+ err error
+}
+
+func (err *updateContextError) Error() string {
+ return fmt.Sprintf("refstore/files: 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}
+}
diff --git a/ref/store/files/update_executor.go b/ref/store/files/update_executor.go
new file mode 100644
index 00000000..749f7061
--- /dev/null
+++ b/ref/store/files/update_executor.go
@@ -0,0 +1,5 @@
+package files
+
+type refUpdateExecutor struct {
+ store *Store
+}
diff --git a/ref/store/files/update_kind.go b/ref/store/files/update_kind.go
new file mode 100644
index 00000000..f04719db
--- /dev/null
+++ b/ref/store/files/update_kind.go
@@ -0,0 +1,14 @@
+package files
+
+type updateKind uint8
+
+const (
+ updateCreate updateKind = iota
+ updateReplace
+ updateDelete
+ updateVerify
+ updateCreateSymbolic
+ updateReplaceSymbolic
+ updateDeleteSymbolic
+ updateVerifySymbolic
+)
diff --git a/ref/store/files/update_lock.go b/ref/store/files/update_lock.go
new file mode 100644
index 00000000..1ce9adbb
--- /dev/null
+++ b/ref/store/files/update_lock.go
@@ -0,0 +1,25 @@
+package files
+
+import (
+ "os"
+ "path"
+)
+
+func (executor *refUpdateExecutor) createUpdateLock(name refPath) error {
+ root := executor.store.rootFor(name.root)
+ dir := path.Dir(name.path)
+
+ if dir != "." {
+ err := root.MkdirAll(dir, 0o755)
+ if err != nil {
+ return err
+ }
+ }
+
+ file, err := root.OpenFile(name.path+".lock", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err != nil {
+ return err
+ }
+
+ return file.Close()
+}
diff --git a/ref/store/files/update_lock_packed.go b/ref/store/files/update_lock_packed.go
new file mode 100644
index 00000000..f74a4f5e
--- /dev/null
+++ b/ref/store/files/update_lock_packed.go
@@ -0,0 +1,44 @@
+package files
+
+import (
+ "errors"
+ "os"
+ "time"
+)
+
+func (executor *refUpdateExecutor) createPackedRefsLock(timeout time.Duration) error {
+ const (
+ initialBackoffMs = 1
+ backoffMaxMultiplier = 1000
+ )
+
+ deadline := time.Now().Add(timeout)
+ multiplier := 1
+ n := 1
+
+ for {
+ file, err := executor.store.commonRoot.OpenFile("packed-refs.lock", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err == nil {
+ return file.Close()
+ }
+
+ if !errors.Is(err, os.ErrExist) {
+ return err
+ }
+
+ if timeout == 0 || (timeout > 0 && time.Now().After(deadline)) {
+ return err
+ }
+
+ backoffMs := multiplier * initialBackoffMs
+ waitMs := (750 + executor.store.lockRand.Intn(500)) * backoffMs / 1000
+ time.Sleep(time.Duration(waitMs) * time.Millisecond)
+
+ multiplier += 2*n + 1
+ if multiplier > backoffMaxMultiplier {
+ multiplier = backoffMaxMultiplier
+ } else {
+ n++
+ }
+ }
+}
diff --git a/ref/store/files/update_operation_prepared.go b/ref/store/files/update_operation_prepared.go
new file mode 100644
index 00000000..c50fea4e
--- /dev/null
+++ b/ref/store/files/update_operation_prepared.go
@@ -0,0 +1,6 @@
+package files
+
+type preparedUpdate struct {
+ op queuedUpdate
+ target resolvedUpdateTarget
+}
diff --git a/ref/store/files/update_operation_queue.go b/ref/store/files/update_operation_queue.go
new file mode 100644
index 00000000..ef7ced2f
--- /dev/null
+++ b/ref/store/files/update_operation_queue.go
@@ -0,0 +1,12 @@
+package files
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+type queuedUpdate struct {
+ name string
+ kind updateKind
+ newID objectid.ObjectID
+ oldID objectid.ObjectID
+ newTarget string
+ oldTarget string
+}
diff --git a/ref/store/files/update_path.go b/ref/store/files/update_path.go
new file mode 100644
index 00000000..2bd42535
--- /dev/null
+++ b/ref/store/files/update_path.go
@@ -0,0 +1,28 @@
+package files
+
+import (
+ "fmt"
+ "strings"
+)
+
+type refPath struct {
+ root rootKind
+ path string
+}
+
+func updateTargetKey(name refPath) string {
+ return fmt.Sprintf("%d:%s", name.root, name.path)
+}
+
+func refPathFromKey(key string) refPath {
+ rootValue, pathValue, ok := strings.Cut(key, ":")
+ if !ok || rootValue == "" {
+ return refPath{root: rootCommon, path: key}
+ }
+
+ if rootValue == "0" {
+ return refPath{root: rootGit, path: pathValue}
+ }
+
+ return refPath{root: rootCommon, path: pathValue}
+}
diff --git a/ref/store/files/update_prepare.go b/ref/store/files/update_prepare.go
new file mode 100644
index 00000000..035c0bc2
--- /dev/null
+++ b/ref/store/files/update_prepare.go
@@ -0,0 +1,48 @@
+package files
+
+func (executor *refUpdateExecutor) prepareUpdates(ops []queuedUpdate) (prepared []preparedUpdate, err error) {
+ defer func() {
+ if err != nil {
+ _ = executor.cleanupPreparedUpdates(prepared)
+ }
+ }()
+
+ prepared, err = executor.resolvePreparedUpdates(ops)
+ if err != nil {
+ return prepared, err
+ }
+
+ deleted, written := collectPreparedWrites(prepared)
+
+ existing, err := executor.collectVisibleNames()
+ if err != nil {
+ return prepared, err
+ }
+
+ for _, name := range written {
+ err = verifyRefnameAvailable(name, existing, written, deleted)
+ if err != nil {
+ return prepared, err
+ }
+ }
+
+ err = executor.prepareUpdateLocks(prepared)
+ if err != nil {
+ return prepared, err
+ }
+
+ hasDeletes := len(deleted) > 0
+ if hasDeletes {
+ err = executor.createPackedRefsLock(executor.store.packedRefsTimeout)
+ if err != nil {
+ return prepared, err
+ }
+ }
+
+ err = executor.verifyPreparedUpdates(prepared)
+ if err != nil {
+ return prepared, err
+ }
+
+ return prepared, nil
+}
diff --git a/ref/store/files/update_prepare_lock.go b/ref/store/files/update_prepare_lock.go
new file mode 100644
index 00000000..67db9628
--- /dev/null
+++ b/ref/store/files/update_prepare_lock.go
@@ -0,0 +1,29 @@
+package files
+
+import "slices"
+
+func (executor *refUpdateExecutor) prepareUpdateLocks(prepared []preparedUpdate) error {
+ lockNames := make([]string, 0, len(prepared))
+ for _, item := range prepared {
+ lockNames = append(lockNames, updateTargetKey(item.target.loc))
+ }
+
+ slices.Sort(lockNames)
+
+ for _, lockKey := range lockNames {
+ lockPath := refPathFromKey(lockKey)
+
+ err := executor.createUpdateLock(lockPath)
+ if err != nil {
+ for _, item := range prepared {
+ if updateTargetKey(item.target.loc) == lockKey {
+ return wrapUpdateError(item.op.name, err)
+ }
+ }
+
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/ref/store/files/update_prepare_resolve.go b/ref/store/files/update_prepare_resolve.go
new file mode 100644
index 00000000..c78d77e2
--- /dev/null
+++ b/ref/store/files/update_prepare_resolve.go
@@ -0,0 +1,43 @@
+package files
+
+import "codeberg.org/lindenii/furgit/ref/store"
+
+func (executor *refUpdateExecutor) resolvePreparedUpdates(ops []queuedUpdate) ([]preparedUpdate, error) {
+ prepared := make([]preparedUpdate, 0, len(ops))
+ targets := make(map[string]struct{}, len(ops))
+
+ for _, op := range ops {
+ target, err := executor.resolveQueuedUpdateTarget(op)
+ if err != nil {
+ return prepared, err
+ }
+
+ targetKey := updateTargetKey(target.loc)
+ if _, exists := targets[targetKey]; exists {
+ return prepared, wrapUpdateError(op.name, &refstore.DuplicateUpdateError{})
+ }
+
+ targets[targetKey] = struct{}{}
+
+ prepared = append(prepared, preparedUpdate{op: op, target: target})
+ }
+
+ return prepared, nil
+}
+
+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
+}
diff --git a/ref/store/files/update_prepare_verify.go b/ref/store/files/update_prepare_verify.go
new file mode 100644
index 00000000..dcd14945
--- /dev/null
+++ b/ref/store/files/update_prepare_verify.go
@@ -0,0 +1,21 @@
+package files
+
+func (executor *refUpdateExecutor) verifyPreparedUpdates(prepared []preparedUpdate) error {
+ for i := range prepared {
+ item := &prepared[i]
+
+ refState, err := executor.directRead(item.target.name)
+ if err != nil {
+ return wrapUpdateError(item.op.name, err)
+ }
+
+ item.target.ref = refState
+
+ err = executor.verifyPreparedUpdateCurrent(*item)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/ref/store/files/update_resolve_target.go b/ref/store/files/update_resolve_target.go
new file mode 100644
index 00000000..7cfb9aa1
--- /dev/null
+++ b/ref/store/files/update_resolve_target.go
@@ -0,0 +1,21 @@
+package files
+
+import "fmt"
+
+func (executor *refUpdateExecutor) resolveQueuedUpdateTarget(op queuedUpdate) (resolvedUpdateTarget, error) {
+ switch op.kind {
+ case updateCreate:
+ return executor.resolveOrdinaryTarget(op.name, true)
+ case updateReplace, updateDelete, updateVerify:
+ return executor.resolveOrdinaryTarget(op.name, false)
+ case updateCreateSymbolic, updateReplaceSymbolic, updateDeleteSymbolic, updateVerifySymbolic:
+ refState, err := executor.directRead(op.name)
+ if err != nil {
+ return resolvedUpdateTarget{}, err
+ }
+
+ return resolvedUpdateTarget{name: op.name, loc: executor.store.loosePath(op.name), ref: refState}, nil
+ default:
+ return resolvedUpdateTarget{}, fmt.Errorf("refstore/files: unsupported update operation %d", op.kind)
+ }
+}
diff --git a/ref/store/files/update_resolve_target_ordinary.go b/ref/store/files/update_resolve_target_ordinary.go
new file mode 100644
index 00000000..d22eafdd
--- /dev/null
+++ b/ref/store/files/update_resolve_target_ordinary.go
@@ -0,0 +1,48 @@
+package files
+
+import (
+ "fmt"
+ "strings"
+
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func (executor *refUpdateExecutor) resolveOrdinaryTarget(name string, allowMissing bool) (resolvedUpdateTarget, error) {
+ cur := name
+ seen := make(map[string]struct{})
+
+ for {
+ if _, ok := seen[cur]; ok {
+ return resolvedUpdateTarget{}, fmt.Errorf("refstore/files: symbolic reference cycle at %q", cur)
+ }
+
+ seen[cur] = struct{}{}
+
+ refState, err := executor.directRead(cur)
+ if err != nil {
+ return resolvedUpdateTarget{}, err
+ }
+
+ switch refState.kind {
+ case directMissing:
+ if !allowMissing {
+ return resolvedUpdateTarget{}, wrapUpdateError(name, refstore.ErrReferenceNotFound)
+ }
+
+ return resolvedUpdateTarget{name: cur, loc: executor.store.loosePath(cur), ref: refState}, nil
+ case directDetached:
+ return resolvedUpdateTarget{name: cur, loc: executor.store.loosePath(cur), ref: refState}, nil
+ case directSymbolic:
+ 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/files: unsupported direct reference state %d", refState.kind)
+ }
+ }
+}
diff --git a/ref/store/files/update_target_resolved.go b/ref/store/files/update_target_resolved.go
new file mode 100644
index 00000000..c29e5938
--- /dev/null
+++ b/ref/store/files/update_target_resolved.go
@@ -0,0 +1,7 @@
+package files
+
+type resolvedUpdateTarget struct {
+ name string
+ loc refPath
+ ref directRefState
+}
diff --git a/ref/store/files/update_validate.go b/ref/store/files/update_validate.go
new file mode 100644
index 00000000..4767957b
--- /dev/null
+++ b/ref/store/files/update_validate.go
@@ -0,0 +1,66 @@
+package files
+
+import (
+ "fmt"
+ "strings"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref/refname"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func (executor *refUpdateExecutor) 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.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.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/files: 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
+}
diff --git a/ref/store/files/update_verify_current.go b/ref/store/files/update_verify_current.go
new file mode 100644
index 00000000..77be54e8
--- /dev/null
+++ b/ref/store/files/update_verify_current.go
@@ -0,0 +1,60 @@
+package files
+
+import (
+ "strings"
+
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func (executor *refUpdateExecutor) verifyPreparedUpdateCurrent(item preparedUpdate) error {
+ switch item.op.kind {
+ case updateCreate:
+ if item.target.ref.kind != directMissing {
+ return wrapUpdateError(item.op.name, &refstore.CreateExistsError{})
+ }
+
+ return nil
+ case updateReplace, updateDelete, updateVerify:
+ if item.target.ref.kind == directMissing {
+ return wrapUpdateError(item.op.name, refstore.ErrReferenceNotFound)
+ }
+
+ if item.target.ref.kind != directDetached {
+ 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 != directMissing {
+ return wrapUpdateError(item.op.name, &refstore.CreateExistsError{})
+ }
+
+ return nil
+ case updateReplaceSymbolic, updateDeleteSymbolic, updateVerifySymbolic:
+ if item.target.ref.kind == directMissing {
+ return wrapUpdateError(item.op.name, refstore.ErrReferenceNotFound)
+ }
+
+ if item.target.ref.kind != directSymbolic {
+ 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
+}
diff --git a/ref/store/files/update_verify_refnames.go b/ref/store/files/update_verify_refnames.go
new file mode 100644
index 00000000..308a9868
--- /dev/null
+++ b/ref/store/files/update_verify_refnames.go
@@ -0,0 +1,41 @@
+package files
+
+import (
+ "strings"
+
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+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+"/")
+}
diff --git a/ref/store/files/update_visible_names.go b/ref/store/files/update_visible_names.go
new file mode 100644
index 00000000..f5792f93
--- /dev/null
+++ b/ref/store/files/update_visible_names.go
@@ -0,0 +1,29 @@
+package files
+
+func (executor *refUpdateExecutor) collectVisibleNames() (map[string]struct{}, error) {
+ names := make(map[string]struct{})
+
+ looseNames, err := executor.store.collectLooseRefNames()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, name := range looseNames {
+ names[name] = struct{}{}
+ }
+
+ packed, err := executor.store.readPackedRefs()
+ if err != nil {
+ return nil, err
+ }
+
+ for name := range packed.byName {
+ if _, exists := names[name]; exists {
+ continue
+ }
+
+ names[name] = struct{}{}
+ }
+
+ return names, nil
+}
diff --git a/ref/store/files/update_write_loose.go b/ref/store/files/update_write_loose.go
new file mode 100644
index 00000000..212be9a8
--- /dev/null
+++ b/ref/store/files/update_write_loose.go
@@ -0,0 +1,59 @@
+package files
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "strings"
+)
+
+func (executor *refUpdateExecutor) writePreparedLooseUpdate(item preparedUpdate) error {
+ root := executor.store.rootFor(item.target.loc.root)
+ lockName := item.target.loc.path + ".lock"
+
+ lock, err := root.OpenFile(lockName, os.O_WRONLY|os.O_TRUNC, 0o644)
+ if err != nil {
+ return err
+ }
+
+ var content string
+
+ switch item.op.kind {
+ case updateCreate, updateReplace:
+ content = item.op.newID.String() + "\n"
+ case updateCreateSymbolic, updateReplaceSymbolic:
+ content = "ref: " + strings.TrimSpace(item.op.newTarget) + "\n"
+ case updateDelete, updateVerify, updateDeleteSymbolic, updateVerifySymbolic:
+ default:
+ _ = lock.Close()
+
+ return fmt.Errorf("refstore/files: unsupported write operation %d", item.op.kind)
+ }
+
+ _, err = lock.WriteString(content)
+ if err != nil {
+ _ = lock.Close()
+
+ return err
+ }
+
+ err = lock.Close()
+ if err != nil {
+ return err
+ }
+
+ dir := path.Dir(item.target.loc.path)
+ if dir != "." {
+ err = root.MkdirAll(dir, 0o755)
+ if err != nil {
+ return err
+ }
+ }
+
+ err = executor.removeEmptyDirTree(item.target.loc)
+ if err != nil {
+ return err
+ }
+
+ return root.Rename(lockName, item.target.loc.path)
+}
diff --git a/ref/store/files/update_write_packed_refs.go b/ref/store/files/update_write_packed_refs.go
new file mode 100644
index 00000000..c7eea780
--- /dev/null
+++ b/ref/store/files/update_write_packed_refs.go
@@ -0,0 +1,98 @@
+package files
+
+import (
+ "errors"
+ "os"
+)
+
+func (executor *refUpdateExecutor) applyPackedRefDeletes(prepared []preparedUpdate) error {
+ _, err := executor.store.commonRoot.Stat("packed-refs.lock")
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+
+ return err
+ }
+
+ packed, err := executor.store.readPackedRefs()
+ if err != nil {
+ return err
+ }
+
+ deleted := make(map[string]struct{})
+ needed := false
+
+ for _, item := range prepared {
+ if item.op.kind != updateDelete && item.op.kind != updateDeleteSymbolic {
+ continue
+ }
+
+ deleted[item.target.name] = struct{}{}
+ if item.target.ref.isPacked {
+ needed = true
+ }
+ }
+
+ if !needed {
+ return nil
+ }
+
+ lock, err := executor.store.commonRoot.OpenFile("packed-refs.new", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err != nil {
+ return err
+ }
+
+ createdTemp := true
+
+ defer func() {
+ if !createdTemp {
+ return
+ }
+
+ _ = executor.store.commonRoot.Remove("packed-refs.new")
+ }()
+
+ _, err = lock.WriteString("# pack-refs with: peeled fully-peeled sorted\n")
+ if err != nil {
+ _ = lock.Close()
+
+ return err
+ }
+
+ for _, entry := range packed.ordered {
+ if _, skip := deleted[entry.Name()]; skip {
+ continue
+ }
+
+ _, err = lock.WriteString(entry.ID.String() + " " + entry.Name() + "\n")
+ if err != nil {
+ _ = lock.Close()
+
+ return err
+ }
+
+ if entry.Peeled != nil {
+ _, err = lock.WriteString("^" + entry.Peeled.String() + "\n")
+ if err != nil {
+ _ = lock.Close()
+
+ return err
+ }
+ }
+ }
+
+ err = lock.Close()
+ if err != nil {
+ return err
+ }
+
+ err = executor.store.commonRoot.Rename("packed-refs.new", "packed-refs")
+ if err != nil {
+ return err
+ }
+
+ createdTemp = false
+
+ return nil
+}
diff --git a/ref/store/files/worktree_test.go b/ref/store/files/worktree_test.go
new file mode 100644
index 00000000..c4df76cf
--- /dev/null
+++ b/ref/store/files/worktree_test.go
@@ -0,0 +1,206 @@
+package files_test
+
+import (
+ "errors"
+ "slices"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ "codeberg.org/lindenii/furgit/ref/store"
+)
+
+func TestFilesWorktreeRefsMatchGit(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, RefFormat: "files"})
+
+ testRepo.Run(t, "commit", "--allow-empty", "-m", "initial")
+
+ initialID, err := objectid.ParseHex(algo, testRepo.Run(t, "rev-parse", "HEAD"))
+ if err != nil {
+ t.Fatalf("ParseHex(initial HEAD): %v", err)
+ }
+
+ testRepo.Run(t, "branch", "wt1", initialID.String())
+ testRepo.Run(t, "branch", "wt2", initialID.String())
+ testRepo.Run(t, "worktree", "add", "wt1", "wt1")
+ testRepo.Run(t, "worktree", "add", "wt2", "wt2")
+
+ testRepo.Run(t, "-C", "wt1", "commit", "--allow-empty", "-m", "wt1")
+ testRepo.Run(t, "-C", "wt2", "commit", "--allow-empty", "-m", "wt2")
+
+ wt1ID, err := objectid.ParseHex(algo, testRepo.Run(t, "-C", "wt1", "rev-parse", "HEAD"))
+ if err != nil {
+ t.Fatalf("ParseHex(wt1 HEAD): %v", err)
+ }
+
+ wt2ID, err := objectid.ParseHex(algo, testRepo.Run(t, "-C", "wt2", "rev-parse", "HEAD"))
+ if err != nil {
+ t.Fatalf("ParseHex(wt2 HEAD): %v", err)
+ }
+
+ testRepo.UpdateRef(t, "refs/worktree/foo", initialID)
+ testRepo.Run(t, "-C", "wt1", "update-ref", "refs/worktree/foo", wt1ID.String())
+ testRepo.Run(t, "-C", "wt2", "update-ref", "refs/worktree/foo", wt2ID.String())
+
+ mainStore := openFilesStore(t, testRepo, algo)
+ repoRoot := testRepo.OpenRoot(t)
+ wt1Store := openFilesStoreAt(t, openGitRootUnder(t, repoRoot, "wt1"), algo)
+ wt2Store := openFilesStoreAt(t, openGitRootUnder(t, repoRoot, "wt2"), algo)
+
+ got, err := mainStore.ResolveToDetached("refs/worktree/foo")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main refs/worktree/foo): %v", err)
+ }
+
+ if got.ID != initialID {
+ t.Fatalf("ResolveToDetached(main refs/worktree/foo) = %s, want %s", got.ID, initialID)
+ }
+
+ got, err = wt1Store.ResolveToDetached("refs/worktree/foo")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(wt1 refs/worktree/foo): %v", err)
+ }
+
+ if got.ID != wt1ID {
+ t.Fatalf("ResolveToDetached(wt1 refs/worktree/foo) = %s, want %s", got.ID, wt1ID)
+ }
+
+ got, err = wt2Store.ResolveToDetached("refs/worktree/foo")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(wt2 refs/worktree/foo): %v", err)
+ }
+
+ if got.ID != wt2ID {
+ t.Fatalf("ResolveToDetached(wt2 refs/worktree/foo) = %s, want %s", got.ID, wt2ID)
+ }
+
+ got, err = wt1Store.ResolveToDetached("main-worktree/HEAD")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(wt1 main-worktree/HEAD): %v", err)
+ }
+
+ if got.ID != initialID {
+ t.Fatalf("ResolveToDetached(wt1 main-worktree/HEAD) = %s, want %s", got.ID, initialID)
+ }
+
+ got, err = mainStore.ResolveToDetached("worktrees/wt1/HEAD")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main worktrees/wt1/HEAD): %v", err)
+ }
+
+ if got.ID != wt1ID {
+ t.Fatalf("ResolveToDetached(main worktrees/wt1/HEAD) = %s, want %s", got.ID, wt1ID)
+ }
+
+ got, err = wt2Store.ResolveToDetached("worktrees/wt1/HEAD")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(wt2 worktrees/wt1/HEAD): %v", err)
+ }
+
+ if got.ID != wt1ID {
+ t.Fatalf("ResolveToDetached(wt2 worktrees/wt1/HEAD) = %s, want %s", got.ID, wt1ID)
+ }
+
+ assertListMatchesGitForEachRef(t, testRepo.Run(t, "for-each-ref", "--format=%(refname)"), mainStore)
+ assertListMatchesGitForEachRef(t, testRepo.Run(t, "-C", "wt1", "for-each-ref", "--format=%(refname)"), wt1Store)
+ })
+}
+
+func TestFilesTransactionPerWorktreeRefsMatchGit(t *testing.T) {
+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+ testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo, RefFormat: "files"})
+ testRepo.Run(t, "commit", "--allow-empty", "-m", "initial")
+ testRepo.Run(t, "branch", "wt1", "HEAD")
+ testRepo.Run(t, "worktree", "add", "wt1", "wt1")
+
+ mainID, err := objectid.ParseHex(algo, testRepo.Run(t, "rev-parse", "HEAD"))
+ if err != nil {
+ t.Fatalf("ParseHex(main HEAD): %v", err)
+ }
+
+ testRepo.Run(t, "-C", "wt1", "commit", "--allow-empty", "-m", "wt1")
+
+ wt1ID, err := objectid.ParseHex(algo, testRepo.Run(t, "-C", "wt1", "rev-parse", "HEAD"))
+ if err != nil {
+ t.Fatalf("ParseHex(wt1 HEAD): %v", err)
+ }
+
+ mainStore := openFilesStore(t, testRepo, algo)
+ repoRoot := testRepo.OpenRoot(t)
+ wt1Store := openFilesStoreAt(t, openGitRootUnder(t, repoRoot, "wt1"), algo)
+
+ mainTx, err := mainStore.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(main): %v", err)
+ }
+
+ err = mainTx.Create("refs/bisect/main-only", mainID)
+ if err != nil {
+ t.Fatalf("Create(main-only) queue: %v", err)
+ }
+
+ err = mainTx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(main-only): %v", err)
+ }
+
+ wtTx, err := wt1Store.BeginTransaction()
+ if err != nil {
+ t.Fatalf("BeginTransaction(wt1): %v", err)
+ }
+
+ err = wtTx.Create("refs/bisect/wt-only", wt1ID)
+ if err != nil {
+ t.Fatalf("Create(wt-only) queue: %v", err)
+ }
+
+ err = wtTx.Commit()
+ if err != nil {
+ t.Fatalf("Commit(wt-only): %v", err)
+ }
+
+ got, err := mainStore.ResolveToDetached("refs/bisect/main-only")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(main-only): %v", err)
+ }
+
+ if got.ID != mainID {
+ t.Fatalf("ResolveToDetached(main-only) = %s, want %s", got.ID, mainID)
+ }
+
+ got, err = wt1Store.ResolveToDetached("refs/bisect/wt-only")
+ if err != nil {
+ t.Fatalf("ResolveToDetached(wt-only): %v", err)
+ }
+
+ if got.ID != wt1ID {
+ t.Fatalf("ResolveToDetached(wt-only) = %s, want %s", got.ID, wt1ID)
+ }
+
+ _, err = mainStore.Resolve("refs/bisect/wt-only")
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ t.Fatalf("Resolve(main sees wt-only) error = %v, want ErrReferenceNotFound", err)
+ }
+
+ _, err = wt1Store.Resolve("refs/bisect/main-only")
+ if !errors.Is(err, refstore.ErrReferenceNotFound) {
+ t.Fatalf("Resolve(wt sees main-only) error = %v, want ErrReferenceNotFound", err)
+ }
+
+ mainRefs := forEachRefLines(testRepo.Run(t, "for-each-ref", "--format=%(refname)", "refs/bisect"))
+
+ wtRefs := forEachRefLines(testRepo.Run(t, "-C", "wt1", "for-each-ref", "--format=%(refname)", "refs/bisect"))
+ if !slices.Equal(mainRefs, []string{"refs/bisect/main-only"}) {
+ t.Fatalf("main for-each-ref refs/bisect = %v", mainRefs)
+ }
+
+ if !slices.Equal(wtRefs, []string{"refs/bisect/wt-only"}) {
+ t.Fatalf("wt1 for-each-ref refs/bisect = %v", wtRefs)
+ }
+ })
+}
diff --git a/ref/store/read_write_store.go b/ref/store/read_write_store.go
new file mode 100644
index 00000000..7be1af61
--- /dev/null
+++ b/ref/store/read_write_store.go
@@ -0,0 +1,8 @@
+package refstore
+
+// ReadWriteStore supports reading, atomic transactions, and non-atomic batches.
+type ReadWriteStore interface {
+ ReadingStore
+ TransactionalStore
+ BatchStore
+}
diff --git a/ref/store/reading.go b/ref/store/reading.go
new file mode 100644
index 00000000..3444f07f
--- /dev/null
+++ b/ref/store/reading.go
@@ -0,0 +1,34 @@
+package refstore
+
+import "codeberg.org/lindenii/furgit/ref"
+
+// ReadingStore reads Git references.
+type ReadingStore interface {
+ // Resolve resolves a reference name to either a symbolic or detached ref.
+ //
+ // Implementations should return value forms (ref.Detached or ref.Symbolic),
+ // not pointer forms. Returned refs do not borrow the store.
+ // If the reference does not exist, implementations should return
+ // ErrReferenceNotFound.
+ Resolve(name string) (ref.Ref, error)
+ // ResolveToDetached resolves a reference name to a detached object ID.
+ //
+ // Implementations may use backend-local lookup semantics for symbolic hops.
+ // Callers that need cross-backend symbolic resolution (for example in a
+ // chain of stores) should prefer repeatedly calling Resolve.
+ //
+ // ResolveToDetached resolves symbolic references only. It does not imply peeling
+ // annotated tag objects.
+ ResolveToDetached(name string) (ref.Detached, error)
+ // List returns references matching pattern.
+ //
+ // The exact pattern language is backend-defined.
+ List(pattern string) ([]ref.Ref, error)
+ // Close releases resources associated with the store.
+ //
+ // Transactions and batches borrowing the store are invalid after Close.
+ //
+ // Repeated calls to Close are undefined behavior unless the implementation
+ // explicitly documents otherwise.
+ Close() error
+}
diff --git a/ref/store/transaction.go b/ref/store/transaction.go
new file mode 100644
index 00000000..a70cd3d4
--- /dev/null
+++ b/ref/store/transaction.go
@@ -0,0 +1,50 @@
+package refstore
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// Transaction stages reference updates for one atomic commit.
+//
+// A transaction borrows its underlying store and is invalid after that store
+// is closed.
+//
+// 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.
+type Transaction interface {
+ // Create creates one detached reference, requiring that the logical
+ // reference does not already exist.
+ Create(name string, newID objectid.ObjectID) error
+ // Update updates one detached reference, requiring that the current logical
+ // reference value matches oldID.
+ Update(name string, newID, oldID objectid.ObjectID) error
+ // Delete deletes one detached reference, requiring that the current logical
+ // reference value matches oldID.
+ Delete(name string, oldID objectid.ObjectID) error
+ // Verify verifies that the current logical reference value matches oldID.
+ Verify(name string, oldID objectid.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 is terminal. Further use of the transaction is undefined behavior.
+ Commit() error
+ // Abort abandons the transaction and releases any resources it holds.
+ //
+ // Abort is terminal. Further use of the transaction is undefined behavior.
+ Abort() error
+}
diff --git a/ref/store/transactional_store.go b/ref/store/transactional_store.go
new file mode 100644
index 00000000..8f5c32cd
--- /dev/null
+++ b/ref/store/transactional_store.go
@@ -0,0 +1,11 @@
+package refstore
+
+// TransactionalStore begins atomic reference transactions.
+//
+// Not every readable reference store is writable. Implementations should only
+// satisfy TransactionalStore when they can stage and commit reference updates
+// atomically within that backend.
+type TransactionalStore interface {
+ // BeginTransaction creates one new mutable transaction.
+ BeginTransaction() (Transaction, error)
+}
diff --git a/ref/store/update_errors.go b/ref/store/update_errors.go
new file mode 100644
index 00000000..f05f37d2
--- /dev/null
+++ b/ref/store/update_errors.go
@@ -0,0 +1,110 @@
+package refstore
+
+import "fmt"
+
+// InvalidNameError indicates that one requested reference name is invalid.
+type InvalidNameError struct {
+ Err error
+}
+
+func (err *InvalidNameError) Error() string {
+ if err == nil || err.Err == nil {
+ return "invalid reference name"
+ }
+
+ return fmt.Sprintf("invalid reference name: %v", err.Err)
+}
+
+func (err *InvalidNameError) Unwrap() error {
+ if err == nil {
+ return nil
+ }
+
+ return err.Err
+}
+
+// InvalidValueError indicates that one requested reference value is invalid.
+type InvalidValueError struct {
+ Err error
+}
+
+func (err *InvalidValueError) Error() string {
+ if err == nil || err.Err == nil {
+ return "invalid reference value"
+ }
+
+ return fmt.Sprintf("invalid reference value: %v", err.Err)
+}
+
+func (err *InvalidValueError) Unwrap() error {
+ if err == nil {
+ return nil
+ }
+
+ return err.Err
+}
+
+// DuplicateUpdateError indicates that one batch or transaction includes a
+// duplicate update target.
+type DuplicateUpdateError struct{}
+
+func (err *DuplicateUpdateError) Error() string {
+ return "duplicate reference update"
+}
+
+// CreateExistsError indicates that one create operation targeted an existing
+// reference.
+type CreateExistsError struct{}
+
+func (err *CreateExistsError) Error() string {
+ return "reference already exists"
+}
+
+// IncorrectOldValueError indicates that one operation's expected old value did
+// not match the current reference value.
+type IncorrectOldValueError struct {
+ Actual string
+ Expected string
+}
+
+func (err *IncorrectOldValueError) Error() string {
+ if err == nil {
+ return "incorrect old value provided"
+ }
+
+ if err.Actual == "" && err.Expected == "" {
+ return "incorrect old value provided"
+ }
+
+ return fmt.Sprintf("incorrect old value provided: got %q, expected %q", err.Actual, err.Expected)
+}
+
+// ExpectedDetachedError indicates that one operation required a detached
+// reference but found a different kind.
+type ExpectedDetachedError struct{}
+
+func (err *ExpectedDetachedError) Error() string {
+ return "expected detached reference"
+}
+
+// ExpectedSymbolicError indicates that one operation required a symbolic
+// reference but found a different kind.
+type ExpectedSymbolicError struct{}
+
+func (err *ExpectedSymbolicError) Error() string {
+ return "expected symbolic reference"
+}
+
+// NameConflictError indicates that one reference name conflicts with another
+// visible or queued reference name.
+type NameConflictError struct {
+ Other string
+}
+
+func (err *NameConflictError) Error() string {
+ if err == nil || err.Other == "" {
+ return "reference name conflict"
+ }
+
+ return fmt.Sprintf("reference name conflict with %q", err.Other)
+}