diff --git a/docs/index.rst b/docs/index.rst index d728af53..838fd07a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,6 +74,7 @@ Table of Contents oid packing references + transactions remotes repository revparse diff --git a/docs/references.rst b/docs/references.rst index 0d06c3c3..56387855 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -88,6 +88,21 @@ Example:: .. autoclass:: pygit2.RefLogEntry :members: +Reference Transactions +======================= + +For atomic updates of multiple references, use transactions. See the +:doc:`transactions` documentation for details. + +Example:: + + # Update multiple refs atomically + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.lock_ref('refs/heads/develop') + txn.set_target('refs/heads/master', new_oid, message='Release') + txn.set_target('refs/heads/develop', dev_oid, message='Continue dev') + Notes ==================== diff --git a/docs/transactions.rst b/docs/transactions.rst new file mode 100644 index 00000000..4645320e --- /dev/null +++ b/docs/transactions.rst @@ -0,0 +1,120 @@ +********************************************************************** +Reference Transactions +********************************************************************** + +Reference transactions allow you to update multiple references atomically. +All reference updates within a transaction either succeed together or fail +together, ensuring repository consistency. + +Basic Usage +=========== + +Use the :meth:`Repository.transaction` method as a context manager. The +transaction commits automatically when the context exits successfully, or +rolls back if an exception is raised:: + + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update master') + +Atomic Multi-Reference Updates +=============================== + +Transactions are useful when you need to update multiple references +atomically:: + + # Swap two branches atomically + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch-a') + txn.lock_ref('refs/heads/branch-b') + + # Get current targets + ref_a = repo.lookup_reference('refs/heads/branch-a') + ref_b = repo.lookup_reference('refs/heads/branch-b') + + # Swap them + txn.set_target('refs/heads/branch-a', ref_b.target, message='Swap') + txn.set_target('refs/heads/branch-b', ref_a.target, message='Swap') + +Automatic Rollback +================== + +If an exception occurs during the transaction, changes are automatically +rolled back:: + + try: + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid) + + # If this raises an exception, the ref update is rolled back + validate_commit(new_oid) + except ValidationError: + # Master still points to its original target + pass + +Manual Commit +============= + +While the context manager is recommended, you can manually manage +transactions:: + + from pygit2 import ReferenceTransaction + + txn = ReferenceTransaction(repo) + try: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update') + txn.commit() + finally: + del txn # Ensure transaction is freed + +API Reference +============= + +Repository Methods +------------------ + +.. automethod:: pygit2.Repository.transaction + +The ReferenceTransaction Type +------------------------------ + +.. autoclass:: pygit2.ReferenceTransaction + :members: + :special-members: __enter__, __exit__ + +Usage Notes +=========== + +- Always lock a reference with :meth:`~ReferenceTransaction.lock_ref` before + modifying it +- Transactions operate on reference names, not Reference objects +- Symbolic references can be updated with + :meth:`~ReferenceTransaction.set_symbolic_target` +- References can be deleted with :meth:`~ReferenceTransaction.remove` +- The signature parameter defaults to the repository's configured identity + +Thread Safety +============= + +Transactions are thread-local and must be used from the thread that created +them. Attempting to use a transaction from a different thread raises +:exc:`RuntimeError`:: + + # This is safe - each thread has its own transaction + def thread1(): + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch1') + txn.set_target('refs/heads/branch1', oid1) + + def thread2(): + with repo.transaction() as txn: + txn.lock_ref('refs/heads/branch2') + txn.set_target('refs/heads/branch2', oid2) + + # Both threads can run concurrently without conflicts + +Different threads can hold transactions simultaneously as long as they don't +attempt to lock the same references. If two threads try to acquire locks in +different orders, libgit2 will detect potential deadlocks and raise an error. diff --git a/pygit2/__init__.py b/pygit2/__init__.py index d06a911e..518f3436 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -366,6 +366,7 @@ from .repository import Repository from .settings import Settings from .submodules import Submodule +from .transaction import ReferenceTransaction from .utils import to_bytes, to_str # Features @@ -971,6 +972,8 @@ def clone_repository( 'Settings', 'submodules', 'Submodule', + 'transaction', + 'ReferenceTransaction', 'utils', 'to_bytes', 'to_str', diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index ea376f13..e73a7ad7 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -239,12 +239,17 @@ class GitRemoteC: class GitReferenceC: pass +class GitTransactionC: + pass + def string(a: char_pointer) -> bytes: ... @overload def new(a: Literal['git_repository **']) -> _Pointer[GitRepositoryC]: ... @overload def new(a: Literal['git_remote **']) -> _Pointer[GitRemoteC]: ... @overload +def new(a: Literal['git_transaction **']) -> _Pointer[GitTransactionC]: ... +@overload def new(a: Literal['git_repository_init_options *']) -> GitRepositoryInitOptionsC: ... @overload def new(a: Literal['git_submodule_update_options *']) -> GitSubmoduleUpdateOptionsC: ... diff --git a/pygit2/_run.py b/pygit2/_run.py index 44f30344..863565f6 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -81,6 +81,7 @@ 'revert.h', 'stash.h', 'submodule.h', + 'transaction.h', 'options.h', 'callbacks.h', # Bridge from libgit2 to Python ] diff --git a/pygit2/decl/transaction.h b/pygit2/decl/transaction.h new file mode 100644 index 00000000..20ac98de --- /dev/null +++ b/pygit2/decl/transaction.h @@ -0,0 +1,8 @@ +int git_transaction_new(git_transaction **out, git_repository *repo); +int git_transaction_lock_ref(git_transaction *tx, const char *refname); +int git_transaction_set_target(git_transaction *tx, const char *refname, const git_oid *target, const git_signature *sig, const char *msg); +int git_transaction_set_symbolic_target(git_transaction *tx, const char *refname, const char *target, const git_signature *sig, const char *msg); +int git_transaction_set_reflog(git_transaction *tx, const char *refname, const git_reflog *reflog); +int git_transaction_remove(git_transaction *tx, const char *refname); +int git_transaction_commit(git_transaction *tx); +void git_transaction_free(git_transaction *tx); diff --git a/pygit2/decl/types.h b/pygit2/decl/types.h index 8bb8fd29..0bef96d3 100644 --- a/pygit2/decl/types.h +++ b/pygit2/decl/types.h @@ -12,6 +12,8 @@ typedef struct git_submodule git_submodule; typedef struct git_transport git_transport; typedef struct git_tree git_tree; typedef struct git_packbuilder git_packbuilder; +typedef struct git_transaction git_transaction; +typedef struct git_reflog git_reflog; typedef int64_t git_off_t; typedef int64_t git_time_t; diff --git a/pygit2/repository.py b/pygit2/repository.py index 509b4d0f..18caa5e7 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -78,6 +78,7 @@ from .references import References from .remotes import RemoteCollection from .submodules import SubmoduleCollection +from .transaction import ReferenceTransaction from .utils import StrArray, to_bytes if TYPE_CHECKING: @@ -120,6 +121,7 @@ def _common_init(self) -> None: self.references = References(self) self.remotes = RemoteCollection(self) self.submodules = SubmoduleCollection(self) + self._active_transaction = None # Get the pointer as the contents of a buffer and store it for # later access @@ -359,6 +361,22 @@ def resolve_refish(self, refish: str) -> tuple[Commit, Reference]: return (commit, reference) # type: ignore + def transaction(self) -> ReferenceTransaction: + """Create a new reference transaction. + + Returns a context manager that commits all reference updates atomically + when the context exits successfully, or performs no updates if an exception + is raised. + + Example:: + + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update') + """ + txn = ReferenceTransaction(self) + return txn + # # Checkout # diff --git a/pygit2/transaction.py b/pygit2/transaction.py new file mode 100644 index 00000000..358b40a9 --- /dev/null +++ b/pygit2/transaction.py @@ -0,0 +1,199 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from __future__ import annotations + +import threading +from typing import TYPE_CHECKING + +from .errors import check_error +from .ffi import C, ffi +from .utils import to_bytes + +if TYPE_CHECKING: + from ._pygit2 import Oid, Signature + from .repository import BaseRepository + + +class ReferenceTransaction: + """Context manager for transactional reference updates. + + A transaction allows multiple reference updates to be performed atomically. + All updates are applied when the transaction is committed, or none are applied + if the transaction is rolled back. + + Example: + with repo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_oid, message='Update master') + # Changes committed automatically on context exit + """ + + def __init__(self, repository: BaseRepository) -> None: + self._repository = repository + self._transaction = ffi.new('git_transaction **') + self._tx = None + self._thread_id = threading.get_ident() + + err = C.git_transaction_new(self._transaction, repository._repo) + check_error(err) + self._tx = self._transaction[0] + + def _check_thread(self) -> None: + """Verify transaction is being used from the same thread that created it.""" + current_thread = threading.get_ident() + if current_thread != self._thread_id: + raise RuntimeError( + f'Transaction created in thread {self._thread_id} ' + f'but used in thread {current_thread}. ' + 'Transactions must be used from the thread that created them.' + ) + + def lock_ref(self, refname: str) -> None: + """Lock a reference in preparation for updating it. + + Args: + refname: Name of the reference to lock (e.g., 'refs/heads/master') + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + err = C.git_transaction_lock_ref(self._tx, c_refname) + check_error(err) + + def set_target( + self, + refname: str, + target: Oid | str, + signature: Signature | None = None, + message: str | None = None, + ) -> None: + """Set the target of a direct reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to update + target: Target OID or hex string + signature: Signature for the reflog (None to use repo identity) + message: Message for the reflog + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + from ._pygit2 import Oid + + c_refname = ffi.new('char[]', to_bytes(refname)) + + # Convert target to OID + if isinstance(target, str): + target = Oid(hex=target) + + c_oid = ffi.new('git_oid *') + ffi.buffer(c_oid)[:] = target.raw + + c_sig = signature._pointer if signature else ffi.NULL + c_msg = ffi.new('char[]', to_bytes(message)) if message else ffi.NULL + + err = C.git_transaction_set_target(self._tx, c_refname, c_oid, c_sig, c_msg) + check_error(err) + + def set_symbolic_target( + self, + refname: str, + target: str, + signature: Signature | None = None, + message: str | None = None, + ) -> None: + """Set the target of a symbolic reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to update + target: Target reference name (e.g., 'refs/heads/master') + signature: Signature for the reflog (None to use repo identity) + message: Message for the reflog + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + c_target = ffi.new('char[]', to_bytes(target)) + c_sig = signature._pointer if signature else ffi.NULL + c_msg = ffi.new('char[]', to_bytes(message)) if message else ffi.NULL + + err = C.git_transaction_set_symbolic_target( + self._tx, c_refname, c_target, c_sig, c_msg + ) + check_error(err) + + def remove(self, refname: str) -> None: + """Remove a reference. + + The reference must be locked first via lock_ref(). + + Args: + refname: Name of the reference to remove + """ + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + c_refname = ffi.new('char[]', to_bytes(refname)) + err = C.git_transaction_remove(self._tx, c_refname) + check_error(err) + + def commit(self) -> None: + """Commit the transaction, applying all queued updates.""" + self._check_thread() + if self._tx is None: + raise ValueError('Transaction already closed') + + err = C.git_transaction_commit(self._tx) + check_error(err) + + def __enter__(self) -> ReferenceTransaction: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self._check_thread() + # Only commit if no exception occurred + if exc_type is None and self._tx is not None: + self.commit() + + # Always free the transaction + if self._tx is not None: + C.git_transaction_free(self._tx) + self._tx = None + + def __del__(self) -> None: + if self._tx is not None: + C.git_transaction_free(self._tx) + self._tx = None diff --git a/test/test_transaction.py b/test/test_transaction.py new file mode 100644 index 00000000..5a6e97ed --- /dev/null +++ b/test/test_transaction.py @@ -0,0 +1,327 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +import threading + +import pytest + +from pygit2 import GitError, Oid, Repository +from pygit2.transaction import ReferenceTransaction + + +def test_transaction_context_manager(testrepo: Repository) -> None: + """Test basic transaction with context manager.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + assert str(master_ref.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + + # Create a transaction and update a ref + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_target, message='Test update') + + # Verify the update was applied + master_ref = testrepo.lookup_reference('refs/heads/master') + assert master_ref.target == new_target + + +def test_transaction_rollback_on_exception(testrepo: Repository) -> None: + """Test that transaction rolls back when exception is raised.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + original_target = master_ref.target + + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + # Transaction should not commit if exception is raised + with pytest.raises(RuntimeError): + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.set_target('refs/heads/master', new_target, message='Test update') + raise RuntimeError('Abort transaction') + + # Verify the update was NOT applied + master_ref = testrepo.lookup_reference('refs/heads/master') + assert master_ref.target == original_target + + +def test_transaction_multiple_refs(testrepo: Repository) -> None: + """Test updating multiple refs in a single transaction.""" + master_ref = testrepo.lookup_reference('refs/heads/master') + i18n_ref = testrepo.lookup_reference('refs/heads/i18n') + + new_master = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + new_i18n = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + + with testrepo.transaction() as txn: + txn.lock_ref('refs/heads/master') + txn.lock_ref('refs/heads/i18n') + txn.set_target('refs/heads/master', new_master, message='Update master') + txn.set_target('refs/heads/i18n', new_i18n, message='Update i18n') + + # Verify both updates were applied + master_ref = testrepo.lookup_reference('refs/heads/master') + i18n_ref = testrepo.lookup_reference('refs/heads/i18n') + assert master_ref.target == new_master + assert i18n_ref.target == new_i18n + + +def test_transaction_symbolic_ref(testrepo: Repository) -> None: + """Test updating symbolic reference in transaction.""" + with testrepo.transaction() as txn: + txn.lock_ref('HEAD') + txn.set_symbolic_target('HEAD', 'refs/heads/i18n', message='Switch HEAD') + + head = testrepo.lookup_reference('HEAD') + assert head.target == 'refs/heads/i18n' + + # Restore HEAD to master + with testrepo.transaction() as txn: + txn.lock_ref('HEAD') + txn.set_symbolic_target('HEAD', 'refs/heads/master', message='Restore HEAD') + + +def test_transaction_remove_ref(testrepo: Repository) -> None: + """Test removing a reference in a transaction.""" + # Create a test ref + test_ref_name = 'refs/heads/test-transaction-delete' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(test_ref_name, target) + + # Verify it exists + assert test_ref_name in testrepo.references + + # Remove it in a transaction + with testrepo.transaction() as txn: + txn.lock_ref(test_ref_name) + txn.remove(test_ref_name) + + # Verify it's gone + assert test_ref_name not in testrepo.references + + +def test_transaction_error_without_lock(testrepo: Repository) -> None: + """Test that setting target without lock raises error.""" + new_target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + + with pytest.raises(KeyError, match='not locked'): + with testrepo.transaction() as txn: + # Try to set target without locking first + txn.set_target('refs/heads/master', new_target, message='Should fail') + + +def test_transaction_isolated_across_threads(testrepo: Repository) -> None: + """Test that transactions from different threads are isolated.""" + # Create two test refs + ref1_name = 'refs/heads/thread-test-1' + ref2_name = 'refs/heads/thread-test-2' + target1 = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + target2 = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref1_name, target1) + testrepo.create_reference(ref2_name, target2) + + results = [] + errors = [] + thread1_ref1_locked = threading.Event() + thread2_ref2_locked = threading.Event() + + def update_ref1() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref1_name) + thread1_ref1_locked.set() + thread2_ref2_locked.wait(timeout=5) + txn.set_target(ref1_name, target2, message='Thread 1 update') + results.append('thread1_success') + except Exception as e: + errors.append(('thread1', str(e))) + + def update_ref2() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref2_name) + thread2_ref2_locked.set() + thread1_ref1_locked.wait(timeout=5) + txn.set_target(ref2_name, target1, message='Thread 2 update') + results.append('thread2_success') + except Exception as e: + errors.append(('thread2', str(e))) + + thread1 = threading.Thread(target=update_ref1) + thread2 = threading.Thread(target=update_ref2) + + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + # Both threads should succeed - transactions are isolated + assert len(errors) == 0, f'Errors: {errors}' + assert 'thread1_success' in results + assert 'thread2_success' in results + + # Verify both updates were applied + ref1 = testrepo.lookup_reference(ref1_name) + ref2 = testrepo.lookup_reference(ref2_name) + assert str(ref1.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + assert str(ref2.target) == '5ebeeebb320790caf276b9fc8b24546d63316533' + + +def test_transaction_deadlock_prevention(testrepo: Repository) -> None: + """Test that acquiring locks in different order raises error instead of deadlock.""" + # Create two test refs + ref1_name = 'refs/heads/deadlock-test-1' + ref2_name = 'refs/heads/deadlock-test-2' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(ref1_name, target) + testrepo.create_reference(ref2_name, target) + + thread1_ref1_locked = threading.Event() + thread2_ref2_locked = threading.Event() + errors = [] + successes = [] + + def thread1_task() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref1_name) + thread1_ref1_locked.set() + thread2_ref2_locked.wait(timeout=5) + # this would cause a deadlock, so will throw (GitError) + txn.lock_ref(ref2_name) + # shouldn't get here + successes.append('thread1') + except Exception as e: + errors.append(('thread1', type(e).__name__, str(e))) + + def thread2_task() -> None: + try: + with testrepo.transaction() as txn: + txn.lock_ref(ref2_name) + thread2_ref2_locked.set() + thread1_ref1_locked.wait(timeout=5) + # this would cause a deadlock, so will throw (GitError) + txn.lock_ref(ref2_name) + # shouldn't get here + successes.append('thread2') + except Exception as e: + errors.append(('thread2', type(e).__name__, str(e))) + + thread1 = threading.Thread(target=thread1_task) + thread2 = threading.Thread(target=thread2_task) + + thread1.start() + thread2.start() + thread1.join(timeout=5) + thread2.join(timeout=5) + + # At least one thread should fail with an error (not deadlock) + # If both threads are still alive, we have a deadlock + assert not thread1.is_alive(), 'Thread 1 deadlocked' + assert not thread2.is_alive(), 'Thread 2 deadlocked' + + # Both can't succeed. + # libgit2 doesn't *wait* for locks, so it's possible for neither to succeed + # if they both try to take the second lock at basically the same time. + # The other possibility is that one thread throws, exits its transaction, + # and the other thread is able to acquire the second lock. + assert len(successes) <= 1 and len(errors) >= 1, ( + f'Successes: {successes}; errors: {errors}' + ) + + +def test_transaction_commit_from_wrong_thread(testrepo: Repository) -> None: + """Test that committing a transaction from wrong thread raises error.""" + txn: ReferenceTransaction | None = None + + def create_transaction() -> None: + nonlocal txn + txn = testrepo.transaction().__enter__() + ref_name = 'refs/heads/wrong-thread-test' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + testrepo.create_reference(ref_name, target) + txn.lock_ref(ref_name) + + # Create transaction in thread 1 + thread = threading.Thread(target=create_transaction) + thread.start() + thread.join() + + assert txn is not None + with pytest.raises(RuntimeError): + # Try to commit from main thread (different from creator) doesn't cause libgit2 to crash, + # it raises an exception instead + txn.commit() + + +def test_transaction_nested_same_thread(testrepo: Repository) -> None: + """Test that two concurrent transactions from same thread work with different refs.""" + # Create test refs + ref1_name = 'refs/heads/nested-test-1' + ref2_name = 'refs/heads/nested-test-2' + target1 = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + target2 = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref1_name, target1) + testrepo.create_reference(ref2_name, target2) + + # Nested transactions should work as long as they don't conflict + with testrepo.transaction() as txn1: + txn1.lock_ref(ref1_name) + + with testrepo.transaction() as txn2: + txn2.lock_ref(ref2_name) + txn2.set_target(ref2_name, target1, message='Inner transaction') + + # Inner transaction committed, now update outer + txn1.set_target(ref1_name, target2, message='Outer transaction') + + # Both updates should have been applied + ref1 = testrepo.lookup_reference(ref1_name) + ref2 = testrepo.lookup_reference(ref2_name) + assert str(ref1.target) == '2be5719152d4f82c7302b1c0932d8e5f0a4a0e98' + assert str(ref2.target) == '5ebeeebb320790caf276b9fc8b24546d63316533' + + +def test_transaction_nested_same_ref_conflict(testrepo: Repository) -> None: + """Test that nested transactions fail when trying to lock the same ref.""" + ref_name = 'refs/heads/nested-conflict-test' + target = Oid(hex='5ebeeebb320790caf276b9fc8b24546d63316533') + new_target = Oid(hex='2be5719152d4f82c7302b1c0932d8e5f0a4a0e98') + testrepo.create_reference(ref_name, target) + + with testrepo.transaction() as txn1: + txn1.lock_ref(ref_name) + + # Inner transaction should fail to lock the same ref + with pytest.raises(GitError): + with testrepo.transaction() as txn2: + txn2.lock_ref(ref_name) + + # Outer transaction should still be able to complete + txn1.set_target(ref_name, new_target, message='Outer transaction') + + # Outer transaction's update should have been applied + ref = testrepo.lookup_reference(ref_name) + assert ref.target == new_target