From 48ff647cf4a8bb8f23fcd6b8616f56a8ef72b980 Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Wed, 25 Mar 2026 14:31:16 +0000 Subject: *: refstore -> ref/store --- receivepack/advertise.go | 2 +- receivepack/hook.go | 2 +- receivepack/hooks/reject_force_push.go | 2 +- receivepack/options.go | 2 +- receivepack/service/apply.go | 2 +- receivepack/service/hook.go | 2 +- receivepack/service/options.go | 2 +- ref/store/batch.go | 64 +++++ ref/store/batch_store.go | 7 + ref/store/chain/chain.go | 12 + ref/store/chain/close.go | 8 + ref/store/chain/list.go | 40 +++ ref/store/chain/new.go | 13 + ref/store/chain/resolve.go | 64 +++++ ref/store/doc.go | 2 + ref/store/errors.go | 7 + ref/store/files/batch.go | 10 + ref/store/files/batch_abort.go | 5 + ref/store/files/batch_apply.go | 136 ++++++++++ ref/store/files/batch_begin.go | 13 + ref/store/files/batch_queue.go | 5 + ref/store/files/batch_queue_ops.go | 35 +++ ref/store/files/batch_rejection.go | 19 ++ ref/store/files/batch_result_error.go | 21 ++ ref/store/files/batch_test.go | 116 +++++++++ ref/store/files/broken_ref_error.go | 16 ++ ref/store/files/close.go | 11 + ref/store/files/helpers_test.go | 150 +++++++++++ ref/store/files/new.go | 29 +++ ref/store/files/packed_delete_test.go | 292 ++++++++++++++++++++++ ref/store/files/packed_parse.go | 113 +++++++++ ref/store/files/packed_read.go | 35 +++ ref/store/files/packed_refs.go | 10 + ref/store/files/read_list.go | 76 ++++++ ref/store/files/read_list_collect.go | 78 ++++++ ref/store/files/read_loose.go | 48 ++++ ref/store/files/read_resolve.go | 41 +++ ref/store/files/read_resolve_fully.go | 42 ++++ ref/store/files/resolve_list_test.go | 269 ++++++++++++++++++++ ref/store/files/root_for.go | 13 + ref/store/files/root_kind.go | 8 + ref/store/files/root_loose_path.go | 24 ++ ref/store/files/root_open_common.go | 31 +++ ref/store/files/store.go | 32 +++ ref/store/files/transaction.go | 12 + ref/store/files/transaction_abort.go | 3 + ref/store/files/transaction_begin.go | 13 + ref/store/files/transaction_commit.go | 12 + ref/store/files/transaction_dirs_test.go | 220 ++++++++++++++++ ref/store/files/transaction_names_test.go | 188 ++++++++++++++ ref/store/files/transaction_pseudoref_test.go | 106 ++++++++ ref/store/files/transaction_queue.go | 12 + ref/store/files/transaction_queue_ops.go | 35 +++ ref/store/files/transaction_symbolic_test.go | 154 ++++++++++++ ref/store/files/transaction_update_test.go | 178 +++++++++++++ ref/store/files/trim.go | 10 + ref/store/files/update_cleanup.go | 39 +++ ref/store/files/update_cleanup_parents.go | 35 +++ ref/store/files/update_commit.go | 25 ++ ref/store/files/update_commit_delete.go | 25 ++ ref/store/files/update_dir_tree.go | 59 +++++ ref/store/files/update_direct_read.go | 76 ++++++ ref/store/files/update_direct_ref.go | 20 ++ ref/store/files/update_error.go | 28 +++ ref/store/files/update_executor.go | 5 + ref/store/files/update_kind.go | 14 ++ ref/store/files/update_lock.go | 25 ++ ref/store/files/update_lock_packed.go | 44 ++++ ref/store/files/update_operation_prepared.go | 6 + ref/store/files/update_operation_queue.go | 12 + ref/store/files/update_path.go | 28 +++ ref/store/files/update_prepare.go | 48 ++++ ref/store/files/update_prepare_lock.go | 29 +++ ref/store/files/update_prepare_resolve.go | 43 ++++ ref/store/files/update_prepare_verify.go | 21 ++ ref/store/files/update_resolve_target.go | 21 ++ ref/store/files/update_resolve_target_ordinary.go | 48 ++++ ref/store/files/update_target_resolved.go | 7 + ref/store/files/update_validate.go | 66 +++++ ref/store/files/update_verify_current.go | 60 +++++ ref/store/files/update_verify_refnames.go | 41 +++ ref/store/files/update_visible_names.go | 29 +++ ref/store/files/update_write_loose.go | 59 +++++ ref/store/files/update_write_packed_refs.go | 98 ++++++++ ref/store/files/worktree_test.go | 206 +++++++++++++++ ref/store/read_write_store.go | 8 + ref/store/reading.go | 34 +++ ref/store/transaction.go | 50 ++++ ref/store/transactional_store.go | 11 + ref/store/update_errors.go | 110 ++++++++ refstore/batch.go | 64 ----- refstore/batch_store.go | 7 - refstore/chain/chain.go | 12 - refstore/chain/close.go | 8 - refstore/chain/list.go | 40 --- refstore/chain/new.go | 13 - refstore/chain/resolve.go | 64 ----- refstore/doc.go | 2 - refstore/errors.go | 7 - refstore/files/batch.go | 10 - refstore/files/batch_abort.go | 5 - refstore/files/batch_apply.go | 136 ---------- refstore/files/batch_begin.go | 13 - refstore/files/batch_queue.go | 5 - refstore/files/batch_queue_ops.go | 35 --- refstore/files/batch_rejection.go | 19 -- refstore/files/batch_result_error.go | 21 -- refstore/files/batch_test.go | 116 --------- refstore/files/broken_ref_error.go | 16 -- refstore/files/close.go | 11 - refstore/files/helpers_test.go | 150 ----------- refstore/files/new.go | 29 --- refstore/files/packed_delete_test.go | 292 ---------------------- refstore/files/packed_parse.go | 113 --------- refstore/files/packed_read.go | 35 --- refstore/files/packed_refs.go | 10 - refstore/files/read_list.go | 76 ------ refstore/files/read_list_collect.go | 78 ------ refstore/files/read_loose.go | 48 ---- refstore/files/read_resolve.go | 41 --- refstore/files/read_resolve_fully.go | 42 ---- refstore/files/resolve_list_test.go | 269 -------------------- refstore/files/root_for.go | 13 - refstore/files/root_kind.go | 8 - refstore/files/root_loose_path.go | 24 -- refstore/files/root_open_common.go | 31 --- refstore/files/store.go | 32 --- refstore/files/transaction.go | 12 - refstore/files/transaction_abort.go | 3 - refstore/files/transaction_begin.go | 13 - refstore/files/transaction_commit.go | 12 - refstore/files/transaction_dirs_test.go | 220 ---------------- refstore/files/transaction_names_test.go | 188 -------------- refstore/files/transaction_pseudoref_test.go | 106 -------- refstore/files/transaction_queue.go | 12 - refstore/files/transaction_queue_ops.go | 35 --- refstore/files/transaction_symbolic_test.go | 154 ------------ refstore/files/transaction_update_test.go | 178 ------------- refstore/files/trim.go | 10 - refstore/files/update_cleanup.go | 39 --- refstore/files/update_cleanup_parents.go | 35 --- refstore/files/update_commit.go | 25 -- refstore/files/update_commit_delete.go | 25 -- refstore/files/update_dir_tree.go | 59 ----- refstore/files/update_direct_read.go | 76 ------ refstore/files/update_direct_ref.go | 20 -- refstore/files/update_error.go | 28 --- refstore/files/update_executor.go | 5 - refstore/files/update_kind.go | 14 -- refstore/files/update_lock.go | 25 -- refstore/files/update_lock_packed.go | 44 ---- refstore/files/update_operation_prepared.go | 6 - refstore/files/update_operation_queue.go | 12 - refstore/files/update_path.go | 28 --- refstore/files/update_prepare.go | 48 ---- refstore/files/update_prepare_lock.go | 29 --- refstore/files/update_prepare_resolve.go | 43 ---- refstore/files/update_prepare_verify.go | 21 -- refstore/files/update_resolve_target.go | 21 -- refstore/files/update_resolve_target_ordinary.go | 48 ---- refstore/files/update_target_resolved.go | 7 - refstore/files/update_validate.go | 66 ----- refstore/files/update_verify_current.go | 60 ----- refstore/files/update_verify_refnames.go | 41 --- refstore/files/update_visible_names.go | 29 --- refstore/files/update_write_loose.go | 59 ----- refstore/files/update_write_packed_refs.go | 98 -------- refstore/files/worktree_test.go | 206 --------------- refstore/read_write_store.go | 8 - refstore/reading.go | 34 --- refstore/transaction.go | 50 ---- refstore/transactional_store.go | 11 - refstore/update_errors.go | 110 -------- repository/open.go | 2 +- repository/refs.go | 2 +- repository/repository.go | 2 +- 176 files changed, 4278 insertions(+), 4278 deletions(-) create mode 100644 ref/store/batch.go create mode 100644 ref/store/batch_store.go create mode 100644 ref/store/chain/chain.go create mode 100644 ref/store/chain/close.go create mode 100644 ref/store/chain/list.go create mode 100644 ref/store/chain/new.go create mode 100644 ref/store/chain/resolve.go create mode 100644 ref/store/doc.go create mode 100644 ref/store/errors.go create mode 100644 ref/store/files/batch.go create mode 100644 ref/store/files/batch_abort.go create mode 100644 ref/store/files/batch_apply.go create mode 100644 ref/store/files/batch_begin.go create mode 100644 ref/store/files/batch_queue.go create mode 100644 ref/store/files/batch_queue_ops.go create mode 100644 ref/store/files/batch_rejection.go create mode 100644 ref/store/files/batch_result_error.go create mode 100644 ref/store/files/batch_test.go create mode 100644 ref/store/files/broken_ref_error.go create mode 100644 ref/store/files/close.go create mode 100644 ref/store/files/helpers_test.go create mode 100644 ref/store/files/new.go create mode 100644 ref/store/files/packed_delete_test.go create mode 100644 ref/store/files/packed_parse.go create mode 100644 ref/store/files/packed_read.go create mode 100644 ref/store/files/packed_refs.go create mode 100644 ref/store/files/read_list.go create mode 100644 ref/store/files/read_list_collect.go create mode 100644 ref/store/files/read_loose.go create mode 100644 ref/store/files/read_resolve.go create mode 100644 ref/store/files/read_resolve_fully.go create mode 100644 ref/store/files/resolve_list_test.go create mode 100644 ref/store/files/root_for.go create mode 100644 ref/store/files/root_kind.go create mode 100644 ref/store/files/root_loose_path.go create mode 100644 ref/store/files/root_open_common.go create mode 100644 ref/store/files/store.go create mode 100644 ref/store/files/transaction.go create mode 100644 ref/store/files/transaction_abort.go create mode 100644 ref/store/files/transaction_begin.go create mode 100644 ref/store/files/transaction_commit.go create mode 100644 ref/store/files/transaction_dirs_test.go create mode 100644 ref/store/files/transaction_names_test.go create mode 100644 ref/store/files/transaction_pseudoref_test.go create mode 100644 ref/store/files/transaction_queue.go create mode 100644 ref/store/files/transaction_queue_ops.go create mode 100644 ref/store/files/transaction_symbolic_test.go create mode 100644 ref/store/files/transaction_update_test.go create mode 100644 ref/store/files/trim.go create mode 100644 ref/store/files/update_cleanup.go create mode 100644 ref/store/files/update_cleanup_parents.go create mode 100644 ref/store/files/update_commit.go create mode 100644 ref/store/files/update_commit_delete.go create mode 100644 ref/store/files/update_dir_tree.go create mode 100644 ref/store/files/update_direct_read.go create mode 100644 ref/store/files/update_direct_ref.go create mode 100644 ref/store/files/update_error.go create mode 100644 ref/store/files/update_executor.go create mode 100644 ref/store/files/update_kind.go create mode 100644 ref/store/files/update_lock.go create mode 100644 ref/store/files/update_lock_packed.go create mode 100644 ref/store/files/update_operation_prepared.go create mode 100644 ref/store/files/update_operation_queue.go create mode 100644 ref/store/files/update_path.go create mode 100644 ref/store/files/update_prepare.go create mode 100644 ref/store/files/update_prepare_lock.go create mode 100644 ref/store/files/update_prepare_resolve.go create mode 100644 ref/store/files/update_prepare_verify.go create mode 100644 ref/store/files/update_resolve_target.go create mode 100644 ref/store/files/update_resolve_target_ordinary.go create mode 100644 ref/store/files/update_target_resolved.go create mode 100644 ref/store/files/update_validate.go create mode 100644 ref/store/files/update_verify_current.go create mode 100644 ref/store/files/update_verify_refnames.go create mode 100644 ref/store/files/update_visible_names.go create mode 100644 ref/store/files/update_write_loose.go create mode 100644 ref/store/files/update_write_packed_refs.go create mode 100644 ref/store/files/worktree_test.go create mode 100644 ref/store/read_write_store.go create mode 100644 ref/store/reading.go create mode 100644 ref/store/transaction.go create mode 100644 ref/store/transactional_store.go create mode 100644 ref/store/update_errors.go delete mode 100644 refstore/batch.go delete mode 100644 refstore/batch_store.go delete mode 100644 refstore/chain/chain.go delete mode 100644 refstore/chain/close.go delete mode 100644 refstore/chain/list.go delete mode 100644 refstore/chain/new.go delete mode 100644 refstore/chain/resolve.go delete mode 100644 refstore/doc.go delete mode 100644 refstore/errors.go delete mode 100644 refstore/files/batch.go delete mode 100644 refstore/files/batch_abort.go delete mode 100644 refstore/files/batch_apply.go delete mode 100644 refstore/files/batch_begin.go delete mode 100644 refstore/files/batch_queue.go delete mode 100644 refstore/files/batch_queue_ops.go delete mode 100644 refstore/files/batch_rejection.go delete mode 100644 refstore/files/batch_result_error.go delete mode 100644 refstore/files/batch_test.go delete mode 100644 refstore/files/broken_ref_error.go delete mode 100644 refstore/files/close.go delete mode 100644 refstore/files/helpers_test.go delete mode 100644 refstore/files/new.go delete mode 100644 refstore/files/packed_delete_test.go delete mode 100644 refstore/files/packed_parse.go delete mode 100644 refstore/files/packed_read.go delete mode 100644 refstore/files/packed_refs.go delete mode 100644 refstore/files/read_list.go delete mode 100644 refstore/files/read_list_collect.go delete mode 100644 refstore/files/read_loose.go delete mode 100644 refstore/files/read_resolve.go delete mode 100644 refstore/files/read_resolve_fully.go delete mode 100644 refstore/files/resolve_list_test.go delete mode 100644 refstore/files/root_for.go delete mode 100644 refstore/files/root_kind.go delete mode 100644 refstore/files/root_loose_path.go delete mode 100644 refstore/files/root_open_common.go delete mode 100644 refstore/files/store.go delete mode 100644 refstore/files/transaction.go delete mode 100644 refstore/files/transaction_abort.go delete mode 100644 refstore/files/transaction_begin.go delete mode 100644 refstore/files/transaction_commit.go delete mode 100644 refstore/files/transaction_dirs_test.go delete mode 100644 refstore/files/transaction_names_test.go delete mode 100644 refstore/files/transaction_pseudoref_test.go delete mode 100644 refstore/files/transaction_queue.go delete mode 100644 refstore/files/transaction_queue_ops.go delete mode 100644 refstore/files/transaction_symbolic_test.go delete mode 100644 refstore/files/transaction_update_test.go delete mode 100644 refstore/files/trim.go delete mode 100644 refstore/files/update_cleanup.go delete mode 100644 refstore/files/update_cleanup_parents.go delete mode 100644 refstore/files/update_commit.go delete mode 100644 refstore/files/update_commit_delete.go delete mode 100644 refstore/files/update_dir_tree.go delete mode 100644 refstore/files/update_direct_read.go delete mode 100644 refstore/files/update_direct_ref.go delete mode 100644 refstore/files/update_error.go delete mode 100644 refstore/files/update_executor.go delete mode 100644 refstore/files/update_kind.go delete mode 100644 refstore/files/update_lock.go delete mode 100644 refstore/files/update_lock_packed.go delete mode 100644 refstore/files/update_operation_prepared.go delete mode 100644 refstore/files/update_operation_queue.go delete mode 100644 refstore/files/update_path.go delete mode 100644 refstore/files/update_prepare.go delete mode 100644 refstore/files/update_prepare_lock.go delete mode 100644 refstore/files/update_prepare_resolve.go delete mode 100644 refstore/files/update_prepare_verify.go delete mode 100644 refstore/files/update_resolve_target.go delete mode 100644 refstore/files/update_resolve_target_ordinary.go delete mode 100644 refstore/files/update_target_resolved.go delete mode 100644 refstore/files/update_validate.go delete mode 100644 refstore/files/update_verify_current.go delete mode 100644 refstore/files/update_verify_refnames.go delete mode 100644 refstore/files/update_visible_names.go delete mode 100644 refstore/files/update_write_loose.go delete mode 100644 refstore/files/update_write_packed_refs.go delete mode 100644 refstore/files/worktree_test.go delete mode 100644 refstore/read_write_store.go delete mode 100644 refstore/reading.go delete mode 100644 refstore/transaction.go delete mode 100644 refstore/transactional_store.go delete mode 100644 refstore/update_errors.go diff --git a/receivepack/advertise.go b/receivepack/advertise.go index 5cac5524..772fe680 100644 --- a/receivepack/advertise.go +++ b/receivepack/advertise.go @@ -5,7 +5,7 @@ import ( common "codeberg.org/lindenii/furgit/protocol/v0v1/server" "codeberg.org/lindenii/furgit/ref" - "codeberg.org/lindenii/furgit/refstore" + "codeberg.org/lindenii/furgit/ref/store" ) func advertisedRefs(opts Options) ([]common.AdvertisedRef, error) { diff --git a/receivepack/hook.go b/receivepack/hook.go index 5a9bcb8b..e4dd4de4 100644 --- a/receivepack/hook.go +++ b/receivepack/hook.go @@ -7,7 +7,7 @@ import ( objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/store" "codeberg.org/lindenii/furgit/receivepack/service" - "codeberg.org/lindenii/furgit/refstore" + "codeberg.org/lindenii/furgit/ref/store" ) type HookIO struct { diff --git a/receivepack/hooks/reject_force_push.go b/receivepack/hooks/reject_force_push.go index 9d780078..5c7a8462 100644 --- a/receivepack/hooks/reject_force_push.go +++ b/receivepack/hooks/reject_force_push.go @@ -9,7 +9,7 @@ import ( objectid "codeberg.org/lindenii/furgit/object/id" objectmix "codeberg.org/lindenii/furgit/object/store/mix" receivepack "codeberg.org/lindenii/furgit/receivepack" - "codeberg.org/lindenii/furgit/refstore" + "codeberg.org/lindenii/furgit/ref/store" ) // RejectForcePush rejects updates whose new value is not a fast-forward of the diff --git a/receivepack/options.go b/receivepack/options.go index 8e7ac747..0d01043a 100644 --- a/receivepack/options.go +++ b/receivepack/options.go @@ -5,7 +5,7 @@ import ( objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/store" - "codeberg.org/lindenii/furgit/refstore" + "codeberg.org/lindenii/furgit/ref/store" ) // Options configures one receive-pack invocation. diff --git a/receivepack/service/apply.go b/receivepack/service/apply.go index 137af64a..9c1900a4 100644 --- a/receivepack/service/apply.go +++ b/receivepack/service/apply.go @@ -3,7 +3,7 @@ package service import ( "codeberg.org/lindenii/furgit/internal/utils" objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/refstore" + "codeberg.org/lindenii/furgit/ref/store" ) func (service *Service) applyAtomic(result *Result, commands []Command) error { diff --git a/receivepack/service/hook.go b/receivepack/service/hook.go index 09120ced..f0624233 100644 --- a/receivepack/service/hook.go +++ b/receivepack/service/hook.go @@ -6,7 +6,7 @@ import ( objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/store" - "codeberg.org/lindenii/furgit/refstore" + "codeberg.org/lindenii/furgit/ref/store" ) type HookIO struct { diff --git a/receivepack/service/options.go b/receivepack/service/options.go index a2c8c510..9afcf521 100644 --- a/receivepack/service/options.go +++ b/receivepack/service/options.go @@ -7,7 +7,7 @@ import ( objectid "codeberg.org/lindenii/furgit/object/id" "codeberg.org/lindenii/furgit/object/store" - "codeberg.org/lindenii/furgit/refstore" + "codeberg.org/lindenii/furgit/ref/store" ) type PromotedObjectPermissions struct { 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) +} diff --git a/refstore/batch.go b/refstore/batch.go deleted file mode 100644 index 6a877a2c..00000000 --- a/refstore/batch.go +++ /dev/null @@ -1,64 +0,0 @@ -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/refstore/batch_store.go b/refstore/batch_store.go deleted file mode 100644 index 3ccfdd10..00000000 --- a/refstore/batch_store.go +++ /dev/null @@ -1,7 +0,0 @@ -package refstore - -// BatchStore begins non-atomic reference batches. -type BatchStore interface { - // BeginBatch creates one new queued batch. - BeginBatch() (Batch, error) -} diff --git a/refstore/chain/chain.go b/refstore/chain/chain.go deleted file mode 100644 index 0feb97c3..00000000 --- a/refstore/chain/chain.go +++ /dev/null @@ -1,12 +0,0 @@ -// Package chain provides a wrapper reference storage backend to query a chain -// of backends. -package chain - -import "codeberg.org/lindenii/furgit/refstore" - -// Chain queries multiple reference stores in order. -// -// Chain borrows its backend stores. -type Chain struct { - backends []refstore.ReadingStore -} diff --git a/refstore/chain/close.go b/refstore/chain/close.go deleted file mode 100644 index 6bd74565..00000000 --- a/refstore/chain/close.go +++ /dev/null @@ -1,8 +0,0 @@ -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/refstore/chain/list.go b/refstore/chain/list.go deleted file mode 100644 index c577ca85..00000000 --- a/refstore/chain/list.go +++ /dev/null @@ -1,40 +0,0 @@ -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/refstore/chain/new.go b/refstore/chain/new.go deleted file mode 100644 index f8bdcb13..00000000 --- a/refstore/chain/new.go +++ /dev/null @@ -1,13 +0,0 @@ -package chain - -import "codeberg.org/lindenii/furgit/refstore" - -// 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/refstore/chain/resolve.go b/refstore/chain/resolve.go deleted file mode 100644 index 97dee24b..00000000 --- a/refstore/chain/resolve.go +++ /dev/null @@ -1,64 +0,0 @@ -package chain - -import ( - "errors" - "fmt" - - "codeberg.org/lindenii/furgit/ref" - "codeberg.org/lindenii/furgit/refstore" -) - -// 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/refstore/doc.go b/refstore/doc.go deleted file mode 100644 index 3d6f3908..00000000 --- a/refstore/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package refstore provides interfaces for reference storage backends. -package refstore diff --git a/refstore/errors.go b/refstore/errors.go deleted file mode 100644 index 45583440..00000000 --- a/refstore/errors.go +++ /dev/null @@ -1,7 +0,0 @@ -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/refstore/files/batch.go b/refstore/files/batch.go deleted file mode 100644 index 43eb1a08..00000000 --- a/refstore/files/batch.go +++ /dev/null @@ -1,10 +0,0 @@ -package files - -import "codeberg.org/lindenii/furgit/refstore" - -type Batch struct { - store *Store - ops []queuedUpdate -} - -var _ refstore.Batch = (*Batch)(nil) diff --git a/refstore/files/batch_abort.go b/refstore/files/batch_abort.go deleted file mode 100644 index 0cbd1651..00000000 --- a/refstore/files/batch_abort.go +++ /dev/null @@ -1,5 +0,0 @@ -package files - -func (batch *Batch) Abort() error { - return nil -} diff --git a/refstore/files/batch_apply.go b/refstore/files/batch_apply.go deleted file mode 100644 index 5746aef5..00000000 --- a/refstore/files/batch_apply.go +++ /dev/null @@ -1,136 +0,0 @@ -package files - -import "codeberg.org/lindenii/furgit/refstore" - -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/refstore/files/batch_begin.go b/refstore/files/batch_begin.go deleted file mode 100644 index 06459b2c..00000000 --- a/refstore/files/batch_begin.go +++ /dev/null @@ -1,13 +0,0 @@ -package files - -import "codeberg.org/lindenii/furgit/refstore" - -// BeginBatch creates one new files batch. -// -//nolint:ireturn -func (store *Store) BeginBatch() (refstore.Batch, error) { - return &Batch{ - store: store, - ops: make([]queuedUpdate, 0, 8), - }, nil -} diff --git a/refstore/files/batch_queue.go b/refstore/files/batch_queue.go deleted file mode 100644 index 5937c6fb..00000000 --- a/refstore/files/batch_queue.go +++ /dev/null @@ -1,5 +0,0 @@ -package files - -func (batch *Batch) queue(op queuedUpdate) { - batch.ops = append(batch.ops, op) -} diff --git a/refstore/files/batch_queue_ops.go b/refstore/files/batch_queue_ops.go deleted file mode 100644 index 7434b0c3..00000000 --- a/refstore/files/batch_queue_ops.go +++ /dev/null @@ -1,35 +0,0 @@ -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/refstore/files/batch_rejection.go b/refstore/files/batch_rejection.go deleted file mode 100644 index 3f3569d6..00000000 --- a/refstore/files/batch_rejection.go +++ /dev/null @@ -1,19 +0,0 @@ -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)) -} diff --git a/refstore/files/batch_result_error.go b/refstore/files/batch_result_error.go deleted file mode 100644 index 06d68273..00000000 --- a/refstore/files/batch_result_error.go +++ /dev/null @@ -1,21 +0,0 @@ -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/refstore/files/batch_test.go b/refstore/files/batch_test.go deleted file mode 100644 index d44ef22f..00000000 --- a/refstore/files/batch_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package files_test - -import ( - "errors" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/refstore" -) - -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/refstore/files/broken_ref_error.go b/refstore/files/broken_ref_error.go deleted file mode 100644 index daa40849..00000000 --- a/refstore/files/broken_ref_error.go +++ /dev/null @@ -1,16 +0,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 -} diff --git a/refstore/files/close.go b/refstore/files/close.go deleted file mode 100644 index 58f400a5..00000000 --- a/refstore/files/close.go +++ /dev/null @@ -1,11 +0,0 @@ -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/refstore/files/helpers_test.go b/refstore/files/helpers_test.go deleted file mode 100644 index 12f02694..00000000 --- a/refstore/files/helpers_test.go +++ /dev/null @@ -1,150 +0,0 @@ -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/refstore/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/refstore/files/new.go b/refstore/files/new.go deleted file mode 100644 index bca3a491..00000000 --- a/refstore/files/new.go +++ /dev/null @@ -1,29 +0,0 @@ -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/refstore/files/packed_delete_test.go b/refstore/files/packed_delete_test.go deleted file mode 100644 index 75992a9d..00000000 --- a/refstore/files/packed_delete_test.go +++ /dev/null @@ -1,292 +0,0 @@ -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/refstore" -) - -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/refstore/files/packed_parse.go b/refstore/files/packed_parse.go deleted file mode 100644 index 3662f6ed..00000000 --- a/refstore/files/packed_parse.go +++ /dev/null @@ -1,113 +0,0 @@ -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/refstore/files/packed_read.go b/refstore/files/packed_read.go deleted file mode 100644 index 20800709..00000000 --- a/refstore/files/packed_read.go +++ /dev/null @@ -1,35 +0,0 @@ -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/refstore/files/packed_refs.go b/refstore/files/packed_refs.go deleted file mode 100644 index f3e91d83..00000000 --- a/refstore/files/packed_refs.go +++ /dev/null @@ -1,10 +0,0 @@ -package files - -import ( - "codeberg.org/lindenii/furgit/ref" -) - -type packedRefs struct { - byName map[string]ref.Detached - ordered []ref.Detached -} diff --git a/refstore/files/read_list.go b/refstore/files/read_list.go deleted file mode 100644 index 358ec007..00000000 --- a/refstore/files/read_list.go +++ /dev/null @@ -1,76 +0,0 @@ -package files - -import ( - "errors" - "path" - "slices" - - "codeberg.org/lindenii/furgit/ref" - "codeberg.org/lindenii/furgit/refstore" -) - -// 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/refstore/files/read_list_collect.go b/refstore/files/read_list_collect.go deleted file mode 100644 index f4e2cb69..00000000 --- a/refstore/files/read_list_collect.go +++ /dev/null @@ -1,78 +0,0 @@ -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/refstore/files/read_loose.go b/refstore/files/read_loose.go deleted file mode 100644 index 8c743fb4..00000000 --- a/refstore/files/read_loose.go +++ /dev/null @@ -1,48 +0,0 @@ -package files - -import ( - "errors" - "fmt" - "os" - "strings" - - objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/ref" - "codeberg.org/lindenii/furgit/refstore" -) - -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/refstore/files/read_resolve.go b/refstore/files/read_resolve.go deleted file mode 100644 index 33d5b3e8..00000000 --- a/refstore/files/read_resolve.go +++ /dev/null @@ -1,41 +0,0 @@ -package files - -import ( - "errors" - - "codeberg.org/lindenii/furgit/ref" - "codeberg.org/lindenii/furgit/refstore" -) - -// 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/refstore/files/read_resolve_fully.go b/refstore/files/read_resolve_fully.go deleted file mode 100644 index de58eb6d..00000000 --- a/refstore/files/read_resolve_fully.go +++ /dev/null @@ -1,42 +0,0 @@ -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/refstore/files/resolve_list_test.go b/refstore/files/resolve_list_test.go deleted file mode 100644 index e25a53f4..00000000 --- a/refstore/files/resolve_list_test.go +++ /dev/null @@ -1,269 +0,0 @@ -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/refstore/files/root_for.go b/refstore/files/root_for.go deleted file mode 100644 index cb968ad9..00000000 --- a/refstore/files/root_for.go +++ /dev/null @@ -1,13 +0,0 @@ -package files - -import ( - "os" -) - -func (store *Store) rootFor(kind rootKind) *os.Root { - if kind == rootCommon { - return store.commonRoot - } - - return store.gitRoot -} diff --git a/refstore/files/root_kind.go b/refstore/files/root_kind.go deleted file mode 100644 index d0ae8cf1..00000000 --- a/refstore/files/root_kind.go +++ /dev/null @@ -1,8 +0,0 @@ -package files - -type rootKind uint8 - -const ( - rootGit rootKind = iota - rootCommon -) diff --git a/refstore/files/root_loose_path.go b/refstore/files/root_loose_path.go deleted file mode 100644 index a78d9bf3..00000000 --- a/refstore/files/root_loose_path.go +++ /dev/null @@ -1,24 +0,0 @@ -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/refstore/files/root_open_common.go b/refstore/files/root_open_common.go deleted file mode 100644 index cac98cbc..00000000 --- a/refstore/files/root_open_common.go +++ /dev/null @@ -1,31 +0,0 @@ -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/refstore/files/store.go b/refstore/files/store.go deleted file mode 100644 index ed9b8744..00000000 --- a/refstore/files/store.go +++ /dev/null @@ -1,32 +0,0 @@ -// 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/refstore" -) - -// 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/refstore/files/transaction.go b/refstore/files/transaction.go deleted file mode 100644 index 1babfe60..00000000 --- a/refstore/files/transaction.go +++ /dev/null @@ -1,12 +0,0 @@ -package files - -import ( - "codeberg.org/lindenii/furgit/refstore" -) - -type Transaction struct { - store *Store - ops []queuedUpdate -} - -var _ refstore.Transaction = (*Transaction)(nil) diff --git a/refstore/files/transaction_abort.go b/refstore/files/transaction_abort.go deleted file mode 100644 index cb82e4bf..00000000 --- a/refstore/files/transaction_abort.go +++ /dev/null @@ -1,3 +0,0 @@ -package files - -func (tx *Transaction) Abort() error { return nil } diff --git a/refstore/files/transaction_begin.go b/refstore/files/transaction_begin.go deleted file mode 100644 index 95834a33..00000000 --- a/refstore/files/transaction_begin.go +++ /dev/null @@ -1,13 +0,0 @@ -package files - -import "codeberg.org/lindenii/furgit/refstore" - -// 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/refstore/files/transaction_commit.go b/refstore/files/transaction_commit.go deleted file mode 100644 index 76bcb195..00000000 --- a/refstore/files/transaction_commit.go +++ /dev/null @@ -1,12 +0,0 @@ -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/refstore/files/transaction_dirs_test.go b/refstore/files/transaction_dirs_test.go deleted file mode 100644 index c010ae69..00000000 --- a/refstore/files/transaction_dirs_test.go +++ /dev/null @@ -1,220 +0,0 @@ -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/refstore/files/transaction_names_test.go b/refstore/files/transaction_names_test.go deleted file mode 100644 index 03c288b1..00000000 --- a/refstore/files/transaction_names_test.go +++ /dev/null @@ -1,188 +0,0 @@ -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/refstore" -) - -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/refstore/files/transaction_pseudoref_test.go b/refstore/files/transaction_pseudoref_test.go deleted file mode 100644 index 4e7af666..00000000 --- a/refstore/files/transaction_pseudoref_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package files_test - -import ( - "errors" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/refstore" -) - -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/refstore/files/transaction_queue.go b/refstore/files/transaction_queue.go deleted file mode 100644 index aa2004c3..00000000 --- a/refstore/files/transaction_queue.go +++ /dev/null @@ -1,12 +0,0 @@ -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/refstore/files/transaction_queue_ops.go b/refstore/files/transaction_queue_ops.go deleted file mode 100644 index 047518c4..00000000 --- a/refstore/files/transaction_queue_ops.go +++ /dev/null @@ -1,35 +0,0 @@ -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/refstore/files/transaction_symbolic_test.go b/refstore/files/transaction_symbolic_test.go deleted file mode 100644 index d0a601f1..00000000 --- a/refstore/files/transaction_symbolic_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package files_test - -import ( - "errors" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/refstore" -) - -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/refstore/files/transaction_update_test.go b/refstore/files/transaction_update_test.go deleted file mode 100644 index b9bd5a2d..00000000 --- a/refstore/files/transaction_update_test.go +++ /dev/null @@ -1,178 +0,0 @@ -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/refstore" -) - -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/refstore/files/trim.go b/refstore/files/trim.go deleted file mode 100644 index 69a851dc..00000000 --- a/refstore/files/trim.go +++ /dev/null @@ -1,10 +0,0 @@ -package files - -func isRefWhitespace(r rune) bool { - switch r { - case ' ', '\t', '\n', '\r', '\v', '\f': - return true - default: - return false - } -} diff --git a/refstore/files/update_cleanup.go b/refstore/files/update_cleanup.go deleted file mode 100644 index 5df2d967..00000000 --- a/refstore/files/update_cleanup.go +++ /dev/null @@ -1,39 +0,0 @@ -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/refstore/files/update_cleanup_parents.go b/refstore/files/update_cleanup_parents.go deleted file mode 100644 index c62681fa..00000000 --- a/refstore/files/update_cleanup_parents.go +++ /dev/null @@ -1,35 +0,0 @@ -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/refstore/files/update_commit.go b/refstore/files/update_commit.go deleted file mode 100644 index 3d39e990..00000000 --- a/refstore/files/update_commit.go +++ /dev/null @@ -1,25 +0,0 @@ -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/refstore/files/update_commit_delete.go b/refstore/files/update_commit_delete.go deleted file mode 100644 index 47a600fb..00000000 --- a/refstore/files/update_commit_delete.go +++ /dev/null @@ -1,25 +0,0 @@ -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/refstore/files/update_dir_tree.go b/refstore/files/update_dir_tree.go deleted file mode 100644 index 51fb5cfb..00000000 --- a/refstore/files/update_dir_tree.go +++ /dev/null @@ -1,59 +0,0 @@ -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/refstore/files/update_direct_read.go b/refstore/files/update_direct_read.go deleted file mode 100644 index 4efecdca..00000000 --- a/refstore/files/update_direct_read.go +++ /dev/null @@ -1,76 +0,0 @@ -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 -} diff --git a/refstore/files/update_direct_ref.go b/refstore/files/update_direct_ref.go deleted file mode 100644 index 3b429be0..00000000 --- a/refstore/files/update_direct_ref.go +++ /dev/null @@ -1,20 +0,0 @@ -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/refstore/files/update_error.go b/refstore/files/update_error.go deleted file mode 100644 index d8841d44..00000000 --- a/refstore/files/update_error.go +++ /dev/null @@ -1,28 +0,0 @@ -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/refstore/files/update_executor.go b/refstore/files/update_executor.go deleted file mode 100644 index 749f7061..00000000 --- a/refstore/files/update_executor.go +++ /dev/null @@ -1,5 +0,0 @@ -package files - -type refUpdateExecutor struct { - store *Store -} diff --git a/refstore/files/update_kind.go b/refstore/files/update_kind.go deleted file mode 100644 index f04719db..00000000 --- a/refstore/files/update_kind.go +++ /dev/null @@ -1,14 +0,0 @@ -package files - -type updateKind uint8 - -const ( - updateCreate updateKind = iota - updateReplace - updateDelete - updateVerify - updateCreateSymbolic - updateReplaceSymbolic - updateDeleteSymbolic - updateVerifySymbolic -) diff --git a/refstore/files/update_lock.go b/refstore/files/update_lock.go deleted file mode 100644 index 1ce9adbb..00000000 --- a/refstore/files/update_lock.go +++ /dev/null @@ -1,25 +0,0 @@ -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/refstore/files/update_lock_packed.go b/refstore/files/update_lock_packed.go deleted file mode 100644 index f74a4f5e..00000000 --- a/refstore/files/update_lock_packed.go +++ /dev/null @@ -1,44 +0,0 @@ -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/refstore/files/update_operation_prepared.go b/refstore/files/update_operation_prepared.go deleted file mode 100644 index c50fea4e..00000000 --- a/refstore/files/update_operation_prepared.go +++ /dev/null @@ -1,6 +0,0 @@ -package files - -type preparedUpdate struct { - op queuedUpdate - target resolvedUpdateTarget -} diff --git a/refstore/files/update_operation_queue.go b/refstore/files/update_operation_queue.go deleted file mode 100644 index ef7ced2f..00000000 --- a/refstore/files/update_operation_queue.go +++ /dev/null @@ -1,12 +0,0 @@ -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/refstore/files/update_path.go b/refstore/files/update_path.go deleted file mode 100644 index 2bd42535..00000000 --- a/refstore/files/update_path.go +++ /dev/null @@ -1,28 +0,0 @@ -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/refstore/files/update_prepare.go b/refstore/files/update_prepare.go deleted file mode 100644 index 035c0bc2..00000000 --- a/refstore/files/update_prepare.go +++ /dev/null @@ -1,48 +0,0 @@ -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/refstore/files/update_prepare_lock.go b/refstore/files/update_prepare_lock.go deleted file mode 100644 index 67db9628..00000000 --- a/refstore/files/update_prepare_lock.go +++ /dev/null @@ -1,29 +0,0 @@ -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/refstore/files/update_prepare_resolve.go b/refstore/files/update_prepare_resolve.go deleted file mode 100644 index 9e0e92ab..00000000 --- a/refstore/files/update_prepare_resolve.go +++ /dev/null @@ -1,43 +0,0 @@ -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 -} diff --git a/refstore/files/update_prepare_verify.go b/refstore/files/update_prepare_verify.go deleted file mode 100644 index dcd14945..00000000 --- a/refstore/files/update_prepare_verify.go +++ /dev/null @@ -1,21 +0,0 @@ -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/refstore/files/update_resolve_target.go b/refstore/files/update_resolve_target.go deleted file mode 100644 index 7cfb9aa1..00000000 --- a/refstore/files/update_resolve_target.go +++ /dev/null @@ -1,21 +0,0 @@ -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/refstore/files/update_resolve_target_ordinary.go b/refstore/files/update_resolve_target_ordinary.go deleted file mode 100644 index 34206e0b..00000000 --- a/refstore/files/update_resolve_target_ordinary.go +++ /dev/null @@ -1,48 +0,0 @@ -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) - } - } -} diff --git a/refstore/files/update_target_resolved.go b/refstore/files/update_target_resolved.go deleted file mode 100644 index c29e5938..00000000 --- a/refstore/files/update_target_resolved.go +++ /dev/null @@ -1,7 +0,0 @@ -package files - -type resolvedUpdateTarget struct { - name string - loc refPath - ref directRefState -} diff --git a/refstore/files/update_validate.go b/refstore/files/update_validate.go deleted file mode 100644 index 94a53fb7..00000000 --- a/refstore/files/update_validate.go +++ /dev/null @@ -1,66 +0,0 @@ -package files - -import ( - "fmt" - "strings" - - objectid "codeberg.org/lindenii/furgit/object/id" - "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 -} diff --git a/refstore/files/update_verify_current.go b/refstore/files/update_verify_current.go deleted file mode 100644 index f8035994..00000000 --- a/refstore/files/update_verify_current.go +++ /dev/null @@ -1,60 +0,0 @@ -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 -} diff --git a/refstore/files/update_verify_refnames.go b/refstore/files/update_verify_refnames.go deleted file mode 100644 index 12d67c5f..00000000 --- a/refstore/files/update_verify_refnames.go +++ /dev/null @@ -1,41 +0,0 @@ -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+"/") -} diff --git a/refstore/files/update_visible_names.go b/refstore/files/update_visible_names.go deleted file mode 100644 index f5792f93..00000000 --- a/refstore/files/update_visible_names.go +++ /dev/null @@ -1,29 +0,0 @@ -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/refstore/files/update_write_loose.go b/refstore/files/update_write_loose.go deleted file mode 100644 index 212be9a8..00000000 --- a/refstore/files/update_write_loose.go +++ /dev/null @@ -1,59 +0,0 @@ -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/refstore/files/update_write_packed_refs.go b/refstore/files/update_write_packed_refs.go deleted file mode 100644 index c7eea780..00000000 --- a/refstore/files/update_write_packed_refs.go +++ /dev/null @@ -1,98 +0,0 @@ -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/refstore/files/worktree_test.go b/refstore/files/worktree_test.go deleted file mode 100644 index 4a66b7a6..00000000 --- a/refstore/files/worktree_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package files_test - -import ( - "errors" - "slices" - "testing" - - "codeberg.org/lindenii/furgit/internal/testgit" - objectid "codeberg.org/lindenii/furgit/object/id" - "codeberg.org/lindenii/furgit/refstore" -) - -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/refstore/read_write_store.go b/refstore/read_write_store.go deleted file mode 100644 index 7be1af61..00000000 --- a/refstore/read_write_store.go +++ /dev/null @@ -1,8 +0,0 @@ -package refstore - -// ReadWriteStore supports reading, atomic transactions, and non-atomic batches. -type ReadWriteStore interface { - ReadingStore - TransactionalStore - BatchStore -} diff --git a/refstore/reading.go b/refstore/reading.go deleted file mode 100644 index 3444f07f..00000000 --- a/refstore/reading.go +++ /dev/null @@ -1,34 +0,0 @@ -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/refstore/transaction.go b/refstore/transaction.go deleted file mode 100644 index a70cd3d4..00000000 --- a/refstore/transaction.go +++ /dev/null @@ -1,50 +0,0 @@ -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/refstore/transactional_store.go b/refstore/transactional_store.go deleted file mode 100644 index 8f5c32cd..00000000 --- a/refstore/transactional_store.go +++ /dev/null @@ -1,11 +0,0 @@ -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/refstore/update_errors.go b/refstore/update_errors.go deleted file mode 100644 index f05f37d2..00000000 --- a/refstore/update_errors.go +++ /dev/null @@ -1,110 +0,0 @@ -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) -} diff --git a/repository/open.go b/repository/open.go index c7dca578..aba2c922 100644 --- a/repository/open.go +++ b/repository/open.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - reffiles "codeberg.org/lindenii/furgit/refstore/files" + reffiles "codeberg.org/lindenii/furgit/ref/store/files" ) // Open opens a repository and wires object/ref stores from its on-disk format. diff --git a/repository/refs.go b/repository/refs.go index 83afe047..1337162b 100644 --- a/repository/refs.go +++ b/repository/refs.go @@ -1,6 +1,6 @@ package repository -import "codeberg.org/lindenii/furgit/refstore" +import "codeberg.org/lindenii/furgit/ref/store" // Refs returns the configured ref store. // diff --git a/repository/repository.go b/repository/repository.go index 57007bb9..a9d43729 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -9,7 +9,7 @@ import ( "codeberg.org/lindenii/furgit/object/store" objectloose "codeberg.org/lindenii/furgit/object/store/loose" objectpacked "codeberg.org/lindenii/furgit/object/store/packed" - "codeberg.org/lindenii/furgit/refstore" + "codeberg.org/lindenii/furgit/ref/store" ) // Repository is a thin composition root for repository-local stores. -- cgit v1.3.1-10-gc9f91