shithub: furgit

Download patch

ref: 4a796e64ac576d6a3e3f2fe6174c4aa476ea0c5c
parent: 13507b7704415332e2b00f5f4d291f3be8bd18fa
author: Runxi Yu <runxiyu@umich.edu>
date: Sun Mar 22 23:25:44 EDT 2026

refstore: Improve interfaces, errors, and make batch work

--- a/refstore/batch.go
+++ b/refstore/batch.go
@@ -46,8 +46,19 @@
 	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
-	Error error
+	Name   string
+	Status BatchStatus
+	Error  error
 }
--- a/refstore/files/batch.go
+++ b/refstore/files/batch.go
@@ -3,9 +3,8 @@
 import "codeberg.org/lindenii/furgit/refstore"
 
 type Batch struct {
-	store  *Store
-	ops    []txOp
-	closed bool
+	store *Store
+	ops   []queuedUpdate
 }
 
 var _ refstore.Batch = (*Batch)(nil)
--- a/refstore/files/batch_abort.go
+++ b/refstore/files/batch_abort.go
@@ -1,13 +1,5 @@
 package files
 
-import "errors"
-
 func (batch *Batch) Abort() error {
-	if batch.closed {
-		return errors.New("refstore/files: batch already closed")
-	}
-
-	batch.closed = true
-
 	return nil
 }
--- a/refstore/files/batch_apply.go
+++ b/refstore/files/batch_apply.go
@@ -1,73 +1,135 @@
 package files
 
-import (
-	"errors"
+import "codeberg.org/lindenii/furgit/refstore"
 
-	"codeberg.org/lindenii/furgit/refstore"
-)
-
 func (batch *Batch) Apply() ([]refstore.BatchResult, error) {
-	if batch.closed {
-		return nil, errors.New("refstore/files: batch already closed")
-	}
-
 	results := make([]refstore.BatchResult, len(batch.ops))
-	seen := make(map[string]struct{}, len(batch.ops))
+	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
 
-		if _, exists := seen[op.name]; exists {
-			batch.closed = true
+		err := executor.validateQueuedUpdate(op)
+		if err != nil {
+			results[i].Status = refstore.BatchStatusRejected
+			results[i].Error = batchResultError(err)
 
-			err := errors.New("refstore/files: duplicate batch operation for " + `"` + op.name + `"`)
-			for j := i; j < len(results); j++ {
+			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].Error = err
+				results[j].Status = refstore.BatchStatusNotAttempted
+				results[j].Error = batchResultError(err)
 			}
 
 			return results, err
 		}
 
-		seen[op.name] = struct{}{}
-	}
+		targetKey := updateTargetKey(target.loc)
+		if _, exists := seenTargets[targetKey]; exists {
+			results[i].Status = refstore.BatchStatusRejected
+			results[i].Error = &refstore.DuplicateUpdateError{}
 
-	for i, op := range batch.ops {
-		tx := &Transaction{
-			store: batch.store,
-			ops:   []txOp{op},
+			continue
 		}
 
-		err := tx.validateOp(op)
+		seenTargets[targetKey] = struct{}{}
+		remainingIdx = append(remainingIdx, i)
+		remainingOps = append(remainingOps, op)
+	}
+
+	for len(remainingOps) > 0 {
+		prepared, err := executor.prepareUpdates(remainingOps)
 		if err != nil {
-			results[i].Error = err
+			if isBatchRejected(err) {
+				name := batchResultName(err)
+				rejectedAt := -1
 
-			continue
-		}
+				for i, op := range remainingOps {
+					if op.name == name {
+						rejectedAt = i
 
-		err = tx.Commit()
-		if err == nil {
-			continue
-		}
+						break
+					}
+				}
 
-		if isBatchRejected(err) {
-			results[i].Error = err
+				if rejectedAt < 0 {
+					for _, idx := range remainingIdx {
+						results[idx].Status = refstore.BatchStatusNotAttempted
+						results[idx].Error = batchResultError(err)
+					}
 
-			continue
+					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:]...)
+
+				continue
+			}
+
+			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
 		}
 
-		batch.closed = true
-		results[i].Error = err
+		err = executor.commitPreparedUpdates(prepared)
+		if err != 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
 
-		for j := i + 1; j < len(results); j++ {
-			results[j].Name = batch.ops[j].name
-			results[j].Error = err
+					continue
+				}
+
+				results[idx].Status = refstore.BatchStatusNotAttempted
+				results[idx].Error = batchResultError(err)
+			}
+
+			return results, err
 		}
 
-		return results, err
-	}
+		for _, idx := range remainingIdx {
+			results[idx].Status = refstore.BatchStatusApplied
+		}
 
-	batch.closed = true
+		return results, nil
+	}
 
 	return results, nil
 }
--- a/refstore/files/batch_begin.go
+++ b/refstore/files/batch_begin.go
@@ -8,6 +8,6 @@
 func (store *Store) BeginBatch() (refstore.Batch, error) {
 	return &Batch{
 		store: store,
-		ops:   make([]txOp, 0, 8),
+		ops:   make([]queuedUpdate, 0, 8),
 	}, nil
 }
--- a/refstore/files/batch_queue.go
+++ b/refstore/files/batch_queue.go
@@ -1,9 +1,5 @@
 package files
 
-func (batch *Batch) queue(op txOp) {
-	if batch.closed {
-		return
-	}
-
+func (batch *Batch) queue(op queuedUpdate) {
 	batch.ops = append(batch.ops, op)
 }
--- a/refstore/files/batch_queue_ops.go
+++ b/refstore/files/batch_queue_ops.go
@@ -3,33 +3,33 @@
 import "codeberg.org/lindenii/furgit/objectid"
 
 func (batch *Batch) Create(name string, newID objectid.ObjectID) {
-	batch.queue(txOp{name: name, kind: txCreate, newID: newID})
+	batch.queue(queuedUpdate{name: name, kind: updateCreate, newID: newID})
 }
 
 func (batch *Batch) Update(name string, newID, oldID objectid.ObjectID) {
-	batch.queue(txOp{name: name, kind: txUpdate, newID: newID, oldID: oldID})
+	batch.queue(queuedUpdate{name: name, kind: updateReplace, newID: newID, oldID: oldID})
 }
 
 func (batch *Batch) Delete(name string, oldID objectid.ObjectID) {
-	batch.queue(txOp{name: name, kind: txDelete, oldID: oldID})
+	batch.queue(queuedUpdate{name: name, kind: updateDelete, oldID: oldID})
 }
 
 func (batch *Batch) Verify(name string, oldID objectid.ObjectID) {
-	batch.queue(txOp{name: name, kind: txVerify, oldID: oldID})
+	batch.queue(queuedUpdate{name: name, kind: updateVerify, oldID: oldID})
 }
 
 func (batch *Batch) CreateSymbolic(name, newTarget string) {
-	batch.queue(txOp{name: name, kind: txCreateSymbolic, newTarget: newTarget})
+	batch.queue(queuedUpdate{name: name, kind: updateCreateSymbolic, newTarget: newTarget})
 }
 
 func (batch *Batch) UpdateSymbolic(name, newTarget, oldTarget string) {
-	batch.queue(txOp{name: name, kind: txUpdateSymbolic, newTarget: newTarget, oldTarget: oldTarget})
+	batch.queue(queuedUpdate{name: name, kind: updateReplaceSymbolic, newTarget: newTarget, oldTarget: oldTarget})
 }
 
 func (batch *Batch) DeleteSymbolic(name, oldTarget string) {
-	batch.queue(txOp{name: name, kind: txDeleteSymbolic, oldTarget: oldTarget})
+	batch.queue(queuedUpdate{name: name, kind: updateDeleteSymbolic, oldTarget: oldTarget})
 }
 
 func (batch *Batch) VerifySymbolic(name, oldTarget string) {
-	batch.queue(txOp{name: name, kind: txVerifySymbolic, oldTarget: oldTarget})
+	batch.queue(queuedUpdate{name: name, kind: updateVerifySymbolic, oldTarget: oldTarget})
 }
--- a/refstore/files/batch_reject.go
+++ /dev/null
@@ -1,35 +1,0 @@
-package files
-
-import (
-	"errors"
-	"strings"
-
-	"codeberg.org/lindenii/furgit/objectid"
-	"codeberg.org/lindenii/furgit/ref/refname"
-	"codeberg.org/lindenii/furgit/refstore"
-)
-
-func isBatchRejected(err error) bool {
-	var nameErr *refname.NameError
-
-	if errors.As(err, &nameErr) {
-		return true
-	}
-
-	if errors.Is(err, objectid.ErrInvalidAlgorithm) || errors.Is(err, refstore.ErrReferenceNotFound) {
-		return true
-	}
-
-	msg := err.Error()
-
-	return strings.Contains(msg, "empty reference name") ||
-		strings.Contains(msg, "empty symbolic target") ||
-		strings.Contains(msg, "empty symbolic old target") ||
-		strings.Contains(msg, "already exists") ||
-		strings.Contains(msg, "is missing") ||
-		strings.Contains(msg, "is not detached") ||
-		strings.Contains(msg, "is not symbolic") ||
-		strings.Contains(msg, "expected") ||
-		strings.Contains(msg, "reference name conflict") ||
-		strings.Contains(msg, "non-empty directory blocks reference")
-}
--- /dev/null
+++ b/refstore/files/batch_rejection.go
@@ -1,0 +1,19 @@
+package files
+
+import (
+	"errors"
+
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+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))
+}
--- /dev/null
+++ b/refstore/files/batch_result_error.go
@@ -1,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
+}
--- a/refstore/files/batch_test.go
+++ b/refstore/files/batch_test.go
@@ -1,10 +1,12 @@
 package files_test
 
 import (
+	"errors"
 	"testing"
 
 	"codeberg.org/lindenii/furgit/internal/testgit"
 	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/refstore"
 )
 
 func TestBatchApplyRejectsStaleDeleteAndAppliesIndependentDelete(t *testing.T) {
@@ -39,14 +41,19 @@
 			t.Fatalf("len(results) = %d, want 2", len(results))
 		}
 
-		if results[0].Error == nil {
-			t.Fatal("stale delete unexpectedly succeeded")
+		if results[0].Status != refstore.BatchStatusRejected {
+			t.Fatalf("results[0].Status = %v, want rejected", results[0].Status)
 		}
 
-		if results[1].Error != nil {
-			t.Fatalf("valid delete failed: %v", results[1].Error)
+		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)
@@ -81,8 +88,8 @@
 		batch.Verify("refs/heads/main", commitID)
 
 		results, err := batch.Apply()
-		if err == nil {
-			t.Fatal("Apply unexpectedly succeeded")
+		if err != nil {
+			t.Fatalf("Apply: %v", err)
 		}
 
 		if len(results) != 2 {
@@ -89,12 +96,20 @@
 			t.Fatalf("len(results) = %d, want 2", len(results))
 		}
 
-		if results[1].Error == nil {
-			t.Fatal("duplicate ref operation did not report an error")
+		if 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 err != nil {
+		if !errors.Is(err, refstore.ErrReferenceNotFound) {
 			t.Fatalf("Resolve(main): %v", err)
 		}
 	})
--- /dev/null
+++ b/refstore/files/broken_ref_error.go
@@ -1,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
+}
--- a/refstore/files/errors.go
+++ /dev/null
@@ -1,16 +1,0 @@
-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
-}
--- a/refstore/files/root_ref_path.go
+++ /dev/null
@@ -1,28 +1,0 @@
-package files
-
-import (
-	"fmt"
-	"strings"
-)
-
-type refPath struct {
-	root rootKind
-	path string
-}
-
-func (tx *Transaction) targetKey(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}
-}
--- a/refstore/files/transaction.go
+++ b/refstore/files/transaction.go
@@ -6,7 +6,7 @@
 
 type Transaction struct {
 	store *Store
-	ops   []txOp
+	ops   []queuedUpdate
 }
 
 var _ refstore.Transaction = (*Transaction)(nil)
--- a/refstore/files/transaction_begin.go
+++ b/refstore/files/transaction_begin.go
@@ -8,6 +8,6 @@
 func (store *Store) BeginTransaction() (refstore.Transaction, error) {
 	return &Transaction{
 		store: store,
-		ops:   make([]txOp, 0, 8),
+		ops:   make([]queuedUpdate, 0, 8),
 	}, nil
 }
--- a/refstore/files/transaction_cleanup.go
+++ /dev/null
@@ -1,39 +1,0 @@
-package files
-
-import (
-	"errors"
-	"os"
-	"slices"
-)
-
-func (tx *Transaction) cleanup(prepared []preparedTxOp) error {
-	var firstErr error
-
-	lockNames := make([]string, 0, len(prepared)+1)
-	for _, item := range prepared {
-		lockNames = append(lockNames, tx.targetKey(item.target.loc))
-	}
-
-	lockNames = append(lockNames, tx.targetKey(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 := tx.store.rootFor(lockPath.root)
-
-		err := root.Remove(lockName)
-		if err == nil || errors.Is(err, os.ErrNotExist) {
-			tx.tryRemoveEmptyParentPaths(lockPath.root, lockName)
-
-			continue
-		}
-
-		if firstErr == nil {
-			firstErr = err
-		}
-	}
-
-	return firstErr
-}
--- a/refstore/files/transaction_cleanup_parents.go
+++ /dev/null
@@ -1,35 +1,0 @@
-package files
-
-import (
-	"errors"
-	"os"
-	"path"
-)
-
-func (tx *Transaction) tryRemoveEmptyParents(name string) {
-	loc := tx.store.loosePath(name)
-	tx.tryRemoveEmptyParentPaths(loc.root, loc.path)
-}
-
-func (tx *Transaction) tryRemoveEmptyParentPaths(kind rootKind, name string) {
-	root := tx.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)
-	}
-}
--- a/refstore/files/transaction_commit.go
+++ b/refstore/files/transaction_commit.go
@@ -1,50 +1,11 @@
 package files
 
-import (
-	"errors"
-	"os"
-)
-
 func (tx *Transaction) Commit() error {
-	prepared, err := tx.prepare()
+	executor := &refUpdateExecutor{store: tx.store}
+	prepared, err := executor.prepareUpdates(tx.ops)
 	if err != nil {
 		return err
 	}
 
-	defer func() {
-		_ = tx.cleanup(prepared)
-	}()
-
-	for _, item := range prepared {
-		if item.op.kind == txDelete || item.op.kind == txDeleteSymbolic || item.op.kind == txVerify || item.op.kind == txVerifySymbolic {
-			continue
-		}
-
-		err = tx.writeLoose(item)
-		if err != nil {
-			return err
-		}
-	}
-
-	err = tx.applyPackedDeletes(prepared)
-	if err != nil {
-		return err
-	}
-
-	for _, item := range prepared {
-		switch item.op.kind {
-		case txDelete, txDeleteSymbolic:
-			if item.target.ref.isLoose {
-				err = tx.store.rootFor(item.target.loc.root).Remove(item.target.loc.path)
-				if err != nil && !errors.Is(err, os.ErrNotExist) {
-					return err
-				}
-
-				tx.tryRemoveEmptyParents(item.target.name)
-			}
-		case txCreate, txUpdate, txVerify, txCreateSymbolic, txUpdateSymbolic, txVerifySymbolic:
-		}
-	}
-
-	return nil
+	return executor.commitPreparedUpdates(prepared)
 }
--- a/refstore/files/transaction_dir_tree.go
+++ /dev/null
@@ -1,59 +1,0 @@
-package files
-
-import (
-	"errors"
-	"fmt"
-	"os"
-	"path"
-)
-
-func (tx *Transaction) removeEmptyDirTree(name refPath) error {
-	root := tx.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 tx.removeEmptyDirTreeRecursive(name)
-}
-
-func (tx *Transaction) removeEmptyDirTreeRecursive(name refPath) error {
-	root := tx.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 = tx.removeEmptyDirTreeRecursive(refPath{
-			root: name.root,
-			path: path.Join(name.path, entry.Name()),
-		})
-		if err != nil {
-			return err
-		}
-	}
-
-	return root.Remove(name.path)
-}
--- a/refstore/files/transaction_direct_read.go
+++ /dev/null
@@ -1,76 +1,0 @@
-package files
-
-import (
-	"errors"
-	"fmt"
-
-	"codeberg.org/lindenii/furgit/ref"
-	"codeberg.org/lindenii/furgit/ref/refname"
-	"codeberg.org/lindenii/furgit/refstore"
-)
-
-func (tx *Transaction) directRead(name string) (directRef, error) {
-	loc := tx.store.loosePath(name)
-	hasPacked := false
-
-	if loc.root == rootCommon && refname.ParseWorktree(name).Type == refname.WorktreeShared {
-		packed, packedErr := tx.store.readPackedRefs()
-		if packedErr != nil {
-			return directRef{}, packedErr
-		}
-
-		_, hasPacked = packed.byName[name]
-	}
-
-	loose, err := tx.store.readLooseRef(name)
-	if err == nil {
-		switch loose := loose.(type) {
-		case ref.Detached:
-			return directRef{
-				kind:     directDetached,
-				name:     name,
-				id:       loose.ID,
-				isLoose:  true,
-				isPacked: hasPacked,
-			}, nil
-		case ref.Symbolic:
-			return directRef{
-				kind:     directSymbolic,
-				name:     name,
-				target:   loose.Target,
-				isLoose:  true,
-				isPacked: hasPacked,
-			}, nil
-		default:
-			return directRef{}, fmt.Errorf("refstore/files: unsupported reference type %T", loose)
-		}
-	}
-
-	if !errors.Is(err, refstore.ErrReferenceNotFound) {
-		info, statErr := tx.store.rootFor(loc.root).Stat(loc.path)
-		if statErr != nil || !info.IsDir() {
-			return directRef{}, err
-		}
-	}
-
-	if hasPacked {
-		packed, packedErr := tx.store.readPackedRefs()
-		if packedErr != nil {
-			return directRef{}, packedErr
-		}
-
-		detached := packed.byName[name]
-
-		return directRef{
-			kind:     directDetached,
-			name:     name,
-			id:       detached.ID,
-			isPacked: true,
-		}, nil
-	}
-
-	return directRef{
-		kind: directMissing,
-		name: name,
-	}, nil
-}
--- a/refstore/files/transaction_direct_ref.go
+++ /dev/null
@@ -1,20 +1,0 @@
-package files
-
-import "codeberg.org/lindenii/furgit/objectid"
-
-type directKind uint8
-
-const (
-	directMissing directKind = iota
-	directDetached
-	directSymbolic
-)
-
-type directRef struct {
-	kind     directKind
-	name     string
-	id       objectid.ObjectID
-	target   string
-	isLoose  bool
-	isPacked bool
-}
--- a/refstore/files/transaction_kind.go
+++ /dev/null
@@ -1,14 +1,0 @@
-package files
-
-type txKind uint8
-
-const (
-	txCreate txKind = iota
-	txUpdate
-	txDelete
-	txVerify
-	txCreateSymbolic
-	txUpdateSymbolic
-	txDeleteSymbolic
-	txVerifySymbolic
-)
--- a/refstore/files/transaction_lock.go
+++ /dev/null
@@ -1,25 +1,0 @@
-package files
-
-import (
-	"os"
-	"path"
-)
-
-func (tx *Transaction) createLock(name refPath) error {
-	root := tx.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()
-}
--- a/refstore/files/transaction_lock_packed.go
+++ /dev/null
@@ -1,44 +1,0 @@
-package files
-
-import (
-	"errors"
-	"os"
-	"time"
-)
-
-func (tx *Transaction) createPackedLock(timeout time.Duration) error {
-	const (
-		initialBackoffMs     = 1
-		backoffMaxMultiplier = 1000
-	)
-
-	deadline := time.Now().Add(timeout)
-	multiplier := 1
-	n := 1
-
-	for {
-		file, err := tx.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 + tx.store.lockRand.Intn(500)) * backoffMs / 1000
-		time.Sleep(time.Duration(waitMs) * time.Millisecond)
-
-		multiplier += 2*n + 1
-		if multiplier > backoffMaxMultiplier {
-			multiplier = backoffMaxMultiplier
-		} else {
-			n++
-		}
-	}
-}
--- a/refstore/files/transaction_operation.go
+++ /dev/null
@@ -1,23 +1,0 @@
-package files
-
-import "codeberg.org/lindenii/furgit/objectid"
-
-type txOp struct {
-	name      string
-	kind      txKind
-	newID     objectid.ObjectID
-	oldID     objectid.ObjectID
-	newTarget string
-	oldTarget string
-}
-
-type preparedTxOp struct {
-	op     txOp
-	target resolvedWriteTarget
-}
-
-type resolvedWriteTarget struct {
-	name string
-	loc  refPath
-	ref  directRef
-}
--- a/refstore/files/transaction_prepare.go
+++ /dev/null
@@ -1,102 +1,0 @@
-package files
-
-import (
-	"fmt"
-	"slices"
-)
-
-func (tx *Transaction) prepare() (prepared []preparedTxOp, err error) {
-	prepared = make([]preparedTxOp, 0, len(tx.ops))
-
-	defer func() {
-		if err != nil {
-			_ = tx.cleanup(prepared)
-		}
-	}()
-
-	targets := make(map[string]struct{}, len(tx.ops))
-
-	for _, op := range tx.ops {
-		target, err := tx.resolveTarget(op)
-		if err != nil {
-			return prepared, err
-		}
-
-		targetKey := tx.targetKey(target.loc)
-		if _, exists := targets[targetKey]; exists {
-			return prepared, fmt.Errorf("refstore/files: duplicate transaction operation for %q", target.name)
-		}
-
-		targets[targetKey] = struct{}{}
-
-		prepared = append(prepared, preparedTxOp{
-			op:     op,
-			target: target,
-		})
-	}
-
-	deleted := make(map[string]struct{})
-	written := make([]string, 0, len(prepared))
-
-	for _, item := range prepared {
-		switch item.op.kind {
-		case txDelete, txDeleteSymbolic:
-			deleted[item.target.name] = struct{}{}
-		case txCreate, txUpdate, txCreateSymbolic, txUpdateSymbolic:
-			written = append(written, item.target.name)
-		case txVerify, txVerifySymbolic:
-		}
-	}
-
-	existing, err := tx.visibleNames()
-	if err != nil {
-		return prepared, err
-	}
-
-	for _, name := range written {
-		err = verifyRefnameAvailable(name, existing, written, deleted)
-		if err != nil {
-			return prepared, err
-		}
-	}
-
-	lockNames := make([]string, 0, len(prepared))
-	for _, item := range prepared {
-		lockNames = append(lockNames, tx.targetKey(item.target.loc))
-	}
-
-	slices.Sort(lockNames)
-
-	for _, lockKey := range lockNames {
-		err = tx.createLock(refPathFromKey(lockKey))
-		if err != nil {
-			return prepared, err
-		}
-	}
-
-	hasDeletes := len(deleted) > 0
-	if hasDeletes {
-		err = tx.createPackedLock(tx.store.packedRefsTimeout)
-		if err != nil {
-			return prepared, err
-		}
-	}
-
-	for i := range prepared {
-		item := &prepared[i]
-
-		refState, err := tx.directRead(item.target.name)
-		if err != nil {
-			return prepared, err
-		}
-
-		item.target.ref = refState
-
-		err = tx.verifyCurrent(*item)
-		if err != nil {
-			return prepared, err
-		}
-	}
-
-	return prepared, nil
-}
--- a/refstore/files/transaction_queue.go
+++ b/refstore/files/transaction_queue.go
@@ -1,7 +1,7 @@
 package files
 
-func (tx *Transaction) queue(op txOp) error {
-	err := tx.validateOp(op)
+func (tx *Transaction) queue(op queuedUpdate) error {
+	err := (&refUpdateExecutor{store: tx.store}).validateQueuedUpdate(op)
 	if err != nil {
 		return err
 	}
--- a/refstore/files/transaction_queue_ops.go
+++ b/refstore/files/transaction_queue_ops.go
@@ -3,33 +3,33 @@
 import "codeberg.org/lindenii/furgit/objectid"
 
 func (tx *Transaction) Create(name string, newID objectid.ObjectID) error {
-	return tx.queue(txOp{name: name, kind: txCreate, newID: newID})
+	return tx.queue(queuedUpdate{name: name, kind: updateCreate, newID: newID})
 }
 
 func (tx *Transaction) Update(name string, newID, oldID objectid.ObjectID) error {
-	return tx.queue(txOp{name: name, kind: txUpdate, newID: newID, oldID: oldID})
+	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(txOp{name: name, kind: txDelete, oldID: oldID})
+	return tx.queue(queuedUpdate{name: name, kind: updateDelete, oldID: oldID})
 }
 
 func (tx *Transaction) Verify(name string, oldID objectid.ObjectID) error {
-	return tx.queue(txOp{name: name, kind: txVerify, oldID: oldID})
+	return tx.queue(queuedUpdate{name: name, kind: updateVerify, oldID: oldID})
 }
 
 func (tx *Transaction) CreateSymbolic(name, newTarget string) error {
-	return tx.queue(txOp{name: name, kind: txCreateSymbolic, newTarget: newTarget})
+	return tx.queue(queuedUpdate{name: name, kind: updateCreateSymbolic, newTarget: newTarget})
 }
 
 func (tx *Transaction) UpdateSymbolic(name, newTarget, oldTarget string) error {
-	return tx.queue(txOp{name: name, kind: txUpdateSymbolic, newTarget: newTarget, oldTarget: oldTarget})
+	return tx.queue(queuedUpdate{name: name, kind: updateReplaceSymbolic, newTarget: newTarget, oldTarget: oldTarget})
 }
 
 func (tx *Transaction) DeleteSymbolic(name, oldTarget string) error {
-	return tx.queue(txOp{name: name, kind: txDeleteSymbolic, oldTarget: oldTarget})
+	return tx.queue(queuedUpdate{name: name, kind: updateDeleteSymbolic, oldTarget: oldTarget})
 }
 
 func (tx *Transaction) VerifySymbolic(name, oldTarget string) error {
-	return tx.queue(txOp{name: name, kind: txVerifySymbolic, oldTarget: oldTarget})
+	return tx.queue(queuedUpdate{name: name, kind: updateVerifySymbolic, oldTarget: oldTarget})
 }
--- a/refstore/files/transaction_resolve_target.go
+++ /dev/null
@@ -1,21 +1,0 @@
-package files
-
-import "fmt"
-
-func (tx *Transaction) resolveTarget(op txOp) (resolvedWriteTarget, error) {
-	switch op.kind {
-	case txCreate:
-		return tx.resolveOrdinaryTarget(op.name, true)
-	case txUpdate, txDelete, txVerify:
-		return tx.resolveOrdinaryTarget(op.name, false)
-	case txCreateSymbolic, txUpdateSymbolic, txDeleteSymbolic, txVerifySymbolic:
-		refState, err := tx.directRead(op.name)
-		if err != nil {
-			return resolvedWriteTarget{}, err
-		}
-
-		return resolvedWriteTarget{name: op.name, loc: tx.store.loosePath(op.name), ref: refState}, nil
-	default:
-		return resolvedWriteTarget{}, fmt.Errorf("refstore/files: unsupported transaction operation %d", op.kind)
-	}
-}
--- a/refstore/files/transaction_resolve_target_ordinary.go
+++ /dev/null
@@ -1,46 +1,0 @@
-package files
-
-import (
-	"fmt"
-	"strings"
-
-	"codeberg.org/lindenii/furgit/refstore"
-)
-
-func (tx *Transaction) resolveOrdinaryTarget(name string, allowMissing bool) (resolvedWriteTarget, error) {
-	cur := name
-	seen := make(map[string]struct{})
-
-	for {
-		if _, ok := seen[cur]; ok {
-			return resolvedWriteTarget{}, fmt.Errorf("refstore/files: symbolic reference cycle at %q", cur)
-		}
-
-		seen[cur] = struct{}{}
-
-		refState, err := tx.directRead(cur)
-		if err != nil {
-			return resolvedWriteTarget{}, err
-		}
-
-		switch refState.kind {
-		case directMissing:
-			if !allowMissing {
-				return resolvedWriteTarget{}, refstore.ErrReferenceNotFound
-			}
-
-			return resolvedWriteTarget{name: cur, loc: tx.store.loosePath(cur), ref: refState}, nil
-		case directDetached:
-			return resolvedWriteTarget{name: cur, loc: tx.store.loosePath(cur), ref: refState}, nil
-		case directSymbolic:
-			target := strings.TrimSpace(refState.target)
-			if target == "" {
-				return resolvedWriteTarget{}, fmt.Errorf("refstore/files: symbolic reference %q has empty target", cur)
-			}
-
-			cur = target
-		default:
-			return resolvedWriteTarget{}, fmt.Errorf("refstore/files: unsupported direct reference state %d", refState.kind)
-		}
-	}
-}
--- a/refstore/files/transaction_validate.go
+++ /dev/null
@@ -1,65 +1,0 @@
-package files
-
-import (
-	"fmt"
-	"strings"
-
-	"codeberg.org/lindenii/furgit/objectid"
-	"codeberg.org/lindenii/furgit/ref/refname"
-)
-
-func (tx *Transaction) validateOp(op txOp) error {
-	if op.name == "" {
-		return fmt.Errorf("refstore/files: empty reference name")
-	}
-
-	switch op.kind {
-	case txCreate, txUpdate:
-		err := refname.ValidateUpdateName(op.name, true)
-		if err != nil {
-			return err
-		}
-
-		if op.newID.Size() == 0 {
-			return objectid.ErrInvalidAlgorithm
-		}
-	case txDelete, txVerify:
-		err := refname.ValidateUpdateName(op.name, false)
-		if err != nil {
-			return err
-		}
-
-		if op.oldID.Size() == 0 {
-			return objectid.ErrInvalidAlgorithm
-		}
-	case txCreateSymbolic, txUpdateSymbolic:
-		err := refname.ValidateUpdateName(op.name, true)
-		if err != nil {
-			return err
-		}
-
-		if strings.TrimSpace(op.newTarget) == "" {
-			return fmt.Errorf("refstore/files: empty symbolic target")
-		}
-
-		err = refname.ValidateSymbolicTarget(op.name, strings.TrimSpace(op.newTarget))
-		if err != nil {
-			return err
-		}
-	case txDeleteSymbolic, txVerifySymbolic:
-		err := refname.ValidateUpdateName(op.name, false)
-		if err != nil {
-			return err
-		}
-	default:
-		return fmt.Errorf("refstore/files: unsupported transaction operation %d", op.kind)
-	}
-
-	if op.kind == txUpdateSymbolic || op.kind == txDeleteSymbolic || op.kind == txVerifySymbolic {
-		if strings.TrimSpace(op.oldTarget) == "" {
-			return fmt.Errorf("refstore/files: empty symbolic old target")
-		}
-	}
-
-	return nil
-}
--- a/refstore/files/transaction_verify_current.go
+++ /dev/null
@@ -1,53 +1,0 @@
-package files
-
-import (
-	"fmt"
-	"strings"
-)
-
-func (tx *Transaction) verifyCurrent(item preparedTxOp) error {
-	switch item.op.kind {
-	case txCreate:
-		if item.target.ref.kind != directMissing {
-			return fmt.Errorf("refstore/files: reference %q already exists", item.target.name)
-		}
-
-		return nil
-	case txUpdate, txDelete, txVerify:
-		if item.target.ref.kind == directMissing {
-			return fmt.Errorf("refstore/files: reference %q is missing", item.target.name)
-		}
-
-		if item.target.ref.kind != directDetached {
-			return fmt.Errorf("refstore/files: reference %q is not detached", item.target.name)
-		}
-
-		if item.target.ref.id != item.op.oldID {
-			return fmt.Errorf("refstore/files: reference %q is at %s but expected %s", item.target.name, item.target.ref.id, item.op.oldID)
-		}
-
-		return nil
-	case txCreateSymbolic:
-		if item.target.ref.kind != directMissing {
-			return fmt.Errorf("refstore/files: reference %q already exists", item.target.name)
-		}
-
-		return nil
-	case txUpdateSymbolic, txDeleteSymbolic, txVerifySymbolic:
-		if item.target.ref.kind == directMissing {
-			return fmt.Errorf("refstore/files: symbolic reference %q is missing", item.target.name)
-		}
-
-		if item.target.ref.kind != directSymbolic {
-			return fmt.Errorf("refstore/files: reference %q is not symbolic", item.target.name)
-		}
-
-		if strings.TrimSpace(item.target.ref.target) != strings.TrimSpace(item.op.oldTarget) {
-			return fmt.Errorf("refstore/files: reference %q points at %q, expected %q", item.target.name, item.target.ref.target, item.op.oldTarget)
-		}
-
-		return nil
-	default:
-		return fmt.Errorf("refstore/files: unsupported transaction operation %d", item.op.kind)
-	}
-}
--- a/refstore/files/transaction_verify_refnames.go
+++ /dev/null
@@ -1,40 +1,0 @@
-package files
-
-import (
-	"fmt"
-	"strings"
-)
-
-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 fmt.Errorf("refstore/files: reference name conflict between %q and %q", name, existingName)
-		}
-	}
-
-	for _, other := range writes {
-		if other == name {
-			continue
-		}
-
-		if refnamesConflict(name, other) {
-			return fmt.Errorf("refstore/files: reference name conflict between %q and %q", name, other)
-		}
-	}
-
-	return nil
-}
-
-func refnamesConflict(left, right string) bool {
-	return left == right ||
-		strings.HasPrefix(left, right+"/") ||
-		strings.HasPrefix(right, left+"/")
-}
--- a/refstore/files/transaction_visible_names.go
+++ /dev/null
@@ -1,29 +1,0 @@
-package files
-
-func (tx *Transaction) visibleNames() (map[string]struct{}, error) {
-	names := make(map[string]struct{})
-
-	looseNames, err := tx.store.collectLooseRefNames()
-	if err != nil {
-		return nil, err
-	}
-
-	for _, name := range looseNames {
-		names[name] = struct{}{}
-	}
-
-	packed, err := tx.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
-}
--- a/refstore/files/transaction_write_loose.go
+++ /dev/null
@@ -1,59 +1,0 @@
-package files
-
-import (
-	"fmt"
-	"os"
-	"path"
-	"strings"
-)
-
-func (tx *Transaction) writeLoose(item preparedTxOp) error {
-	root := tx.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 txCreate, txUpdate:
-		content = item.op.newID.String() + "\n"
-	case txCreateSymbolic, txUpdateSymbolic:
-		content = "ref: " + strings.TrimSpace(item.op.newTarget) + "\n"
-	case txDelete, txVerify, txDeleteSymbolic, txVerifySymbolic:
-	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 = tx.removeEmptyDirTree(item.target.loc)
-	if err != nil {
-		return err
-	}
-
-	return root.Rename(lockName, item.target.loc.path)
-}
--- a/refstore/files/transaction_write_packed_deltas.go
+++ /dev/null
@@ -1,98 +1,0 @@
-package files
-
-import (
-	"errors"
-	"os"
-)
-
-func (tx *Transaction) applyPackedDeletes(prepared []preparedTxOp) error {
-	_, err := tx.store.commonRoot.Stat("packed-refs.lock")
-	if err != nil {
-		if errors.Is(err, os.ErrNotExist) {
-			return nil
-		}
-
-		return err
-	}
-
-	packed, err := tx.store.readPackedRefs()
-	if err != nil {
-		return err
-	}
-
-	deleted := make(map[string]struct{})
-	needed := false
-
-	for _, item := range prepared {
-		if item.op.kind != txDelete && item.op.kind != txDeleteSymbolic {
-			continue
-		}
-
-		deleted[item.target.name] = struct{}{}
-		if item.target.ref.isPacked {
-			needed = true
-		}
-	}
-
-	if !needed {
-		return nil
-	}
-
-	lock, err := tx.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
-		}
-
-		_ = tx.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 = tx.store.commonRoot.Rename("packed-refs.new", "packed-refs")
-	if err != nil {
-		return err
-	}
-
-	createdTemp = false
-
-	return nil
-}
--- /dev/null
+++ b/refstore/files/update_cleanup.go
@@ -1,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
+}
--- /dev/null
+++ b/refstore/files/update_cleanup_parents.go
@@ -1,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)
+	}
+}
--- /dev/null
+++ b/refstore/files/update_commit.go
@@ -1,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)
+}
--- /dev/null
+++ b/refstore/files/update_commit_delete.go
@@ -1,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
+}
--- /dev/null
+++ b/refstore/files/update_dir_tree.go
@@ -1,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)
+}
--- /dev/null
+++ b/refstore/files/update_direct_read.go
@@ -1,0 +1,76 @@
+package files
+
+import (
+	"errors"
+	"fmt"
+
+	"codeberg.org/lindenii/furgit/ref"
+	"codeberg.org/lindenii/furgit/ref/refname"
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+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
+}
--- /dev/null
+++ b/refstore/files/update_direct_ref.go
@@ -1,0 +1,20 @@
+package files
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+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
+}
--- /dev/null
+++ b/refstore/files/update_error.go
@@ -1,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}
+}
--- /dev/null
+++ b/refstore/files/update_executor.go
@@ -1,0 +1,5 @@
+package files
+
+type refUpdateExecutor struct {
+	store *Store
+}
--- /dev/null
+++ b/refstore/files/update_kind.go
@@ -1,0 +1,14 @@
+package files
+
+type updateKind uint8
+
+const (
+	updateCreate updateKind = iota
+	updateReplace
+	updateDelete
+	updateVerify
+	updateCreateSymbolic
+	updateReplaceSymbolic
+	updateDeleteSymbolic
+	updateVerifySymbolic
+)
--- /dev/null
+++ b/refstore/files/update_lock.go
@@ -1,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()
+}
--- /dev/null
+++ b/refstore/files/update_lock_packed.go
@@ -1,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++
+		}
+	}
+}
--- /dev/null
+++ b/refstore/files/update_operation_prepared.go
@@ -1,0 +1,6 @@
+package files
+
+type preparedUpdate struct {
+	op     queuedUpdate
+	target resolvedUpdateTarget
+}
--- /dev/null
+++ b/refstore/files/update_operation_queue.go
@@ -1,0 +1,12 @@
+package files
+
+import "codeberg.org/lindenii/furgit/objectid"
+
+type queuedUpdate struct {
+	name      string
+	kind      updateKind
+	newID     objectid.ObjectID
+	oldID     objectid.ObjectID
+	newTarget string
+	oldTarget string
+}
--- /dev/null
+++ b/refstore/files/update_path.go
@@ -1,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}
+}
--- /dev/null
+++ b/refstore/files/update_prepare.go
@@ -1,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
+}
--- /dev/null
+++ b/refstore/files/update_prepare_lock.go
@@ -1,0 +1,28 @@
+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
+}
--- /dev/null
+++ b/refstore/files/update_prepare_resolve.go
@@ -1,0 +1,42 @@
+package files
+
+import "codeberg.org/lindenii/furgit/refstore"
+
+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
+}
--- /dev/null
+++ b/refstore/files/update_prepare_verify.go
@@ -1,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
+}
--- /dev/null
+++ b/refstore/files/update_resolve_target.go
@@ -1,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)
+	}
+}
--- /dev/null
+++ b/refstore/files/update_resolve_target_ordinary.go
@@ -1,0 +1,48 @@
+package files
+
+import (
+	"fmt"
+	"strings"
+
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+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)
+		}
+	}
+}
--- /dev/null
+++ b/refstore/files/update_target_resolved.go
@@ -1,0 +1,7 @@
+package files
+
+type resolvedUpdateTarget struct {
+	name string
+	loc  refPath
+	ref  directRefState
+}
--- /dev/null
+++ b/refstore/files/update_validate.go
@@ -1,0 +1,66 @@
+package files
+
+import (
+	"fmt"
+	"strings"
+
+	"codeberg.org/lindenii/furgit/objectid"
+	"codeberg.org/lindenii/furgit/ref/refname"
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+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
+}
--- /dev/null
+++ b/refstore/files/update_verify_current.go
@@ -1,0 +1,60 @@
+package files
+
+import (
+	"strings"
+
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+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
+}
--- /dev/null
+++ b/refstore/files/update_verify_refnames.go
@@ -1,0 +1,41 @@
+package files
+
+import (
+	"strings"
+
+	"codeberg.org/lindenii/furgit/refstore"
+)
+
+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+"/")
+}
--- /dev/null
+++ b/refstore/files/update_visible_names.go
@@ -1,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
+}
--- /dev/null
+++ b/refstore/files/update_write_loose.go
@@ -1,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)
+}
--- /dev/null
+++ b/refstore/files/update_write_packed_refs.go
@@ -1,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
+}
--- /dev/null
+++ b/refstore/update_errors.go
@@ -1,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)
+}
--