This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new 83e7d6c9 Migrate the revision creator that clones from a specific 
revision
83e7d6c9 is described below

commit 83e7d6c931fb8b6e17a6074e087ad863e06e34d3
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Feb 18 16:25:49 2026 +0000

    Migrate the revision creator that clones from a specific revision
---
 atr/post/revisions.py              |  17 +---
 atr/storage/writers/revision.py    |  26 ++++--
 tests/unit/test_create_revision.py | 157 ++++++++++++++++++++++++++++++++++++-
 3 files changed, 181 insertions(+), 19 deletions(-)

diff --git a/atr/post/revisions.py b/atr/post/revisions.py
index 09a74f30..5906200c 100644
--- a/atr/post/revisions.py
+++ b/atr/post/revisions.py
@@ -15,7 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import aioshutil
 import asfquart.base as base
 
 import atr.blueprints.post as post
@@ -24,7 +23,6 @@ import atr.get as get
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
-import atr.util as util
 import atr.web as web
 
 
@@ -50,7 +48,6 @@ async def _set_revision(
 
     async with db.session() as data:
         release = await session.release(project_name, version_name, 
phase=None, data=data)
-        selected_revision_dir = util.release_directory_base(release) / 
selected_revision_number
         if release.phase not in {sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, 
sql.ReleasePhase.RELEASE_PREVIEW}:
             raise base.ASFQuartException("Cannot set revision for non-draft or 
preview release", errorcode=400)
 
@@ -67,18 +64,12 @@ async def _set_revision(
     description = f"Copy of revision {selected_revision_number} through web 
interface"
     async with storage.write(session) as write:
         wacp = await write.as_project_committee_participant(project_name)
-        async with wacp.revision.create_and_manage(
-            project_name, version_name, session.uid, description=description
-        ) as creating:
-            # TODO: Stop create_and_manage from hard linking the parent first
-            await aioshutil.rmtree(creating.interim_path)
-            await util.create_hard_link_clone(selected_revision_dir, 
creating.interim_path)
-
-        if creating.new is None:
-            raise base.ASFQuartException("Internal error: New revision not 
found", errorcode=500)
+        new_revision = await wacp.revision.create_revision(
+            project_name, version_name, session.uid, description=description, 
clone_from=selected_revision_number
+        )
         return await session.redirect(
             get.revisions.selected,
-            success=f"Copied revision {selected_revision_number} to new latest 
revision, {creating.new.number}",
+            success=f"Copied revision {selected_revision_number} to new latest 
revision, {new_revision.number}",
             project_name=project_name,
             version_name=version_name,
         )
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index b2992e06..8009853a 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -278,6 +278,7 @@ class CommitteeParticipant(FoundationCommitter):
         description: str | None = None,
         use_check_cache: bool = True,
         modify: Callable[[pathlib.Path, sql.Revision | None], Awaitable[None]] 
| None = None,
+        clone_from: str | None = None,
     ) -> sql.Revision:
         """Create a new revision."""
         # Get the release
@@ -286,7 +287,18 @@ class CommitteeParticipant(FoundationCommitter):
             release = await data.release(name=release_name, 
_release_policy=True, _project_release_policy=True).demand(
                 RuntimeError("Release does not exist for new revision 
creation")
             )
-            old_revision = await interaction.latest_revision(release)
+            if clone_from is not None:
+                old_revision = await data.revision(release_name=release_name, 
number=clone_from).demand(
+                    RuntimeError(f"Revision {clone_from} does not exist")
+                )
+            else:
+                old_revision = await interaction.latest_revision(release)
+
+        if clone_from is not None:
+            old_release_dir = util.release_directory_base(release) / clone_from
+        else:
+            old_release_dir = util.release_directory(release)
+        merge_enabled = clone_from is None
 
         # Create a temporary directory
         # We ensure, below, that it's removed on any exception
@@ -299,7 +311,6 @@ class CommitteeParticipant(FoundationCommitter):
             # The directory was created by mkdtemp, but it's empty
             if old_revision is not None:
                 # If this is not the first revision, hard link the previous 
revision
-                old_release_dir = util.release_directory(release)
                 await util.create_hard_link_clone(old_release_dir, 
temp_dir_path, do_not_create_dest_dir=True)
             # The directory is either empty or its files are hard linked to 
the previous revision
             if modify is not None:
@@ -338,8 +349,8 @@ class CommitteeParticipant(FoundationCommitter):
                 previous_attestable = await attestable.load(project_name, 
version_name, parent_revision_number)
             base_inodes: dict[str, int] = {}
             base_hashes: dict[str, str] = {}
-            if old_revision is not None:
-                base_dir = util.release_directory(release)
+            if merge_enabled and (old_revision is not None):
+                base_dir = old_release_dir
                 base_inodes = await asyncio.to_thread(util.paths_to_inodes, 
base_dir)
                 base_hashes = dict(previous_attestable.paths) if 
(previous_attestable is not None) else {}
             n_inodes = await asyncio.to_thread(util.paths_to_inodes, 
temp_dir_path)
@@ -375,7 +386,12 @@ class CommitteeParticipant(FoundationCommitter):
 
                 # Merge with the prior revision if there was an intervening 
change
                 prior_name = new_revision.parent_name
-                if (old_revision is not None) and (prior_name is not None) and 
(prior_name != old_revision.name):
+                if (
+                    merge_enabled
+                    and (old_revision is not None)
+                    and (prior_name is not None)
+                    and (prior_name != old_revision.name)
+                ):
                     prior_number = prior_name.split()[-1]
                     prior_dir = util.release_directory_base(release) / 
prior_number
                     await merge.merge(
diff --git a/tests/unit/test_create_revision.py 
b/tests/unit/test_create_revision.py
index d785a6d6..7d836f06 100644
--- a/tests/unit/test_create_revision.py
+++ b/tests/unit/test_create_revision.py
@@ -21,10 +21,158 @@ import unittest.mock as mock
 
 import pytest
 
+import atr.models.sql as sql
 import atr.storage.types as types
 import atr.storage.writers.revision as revision
 
 
+class AsyncContextManager:
+    async def __aenter__(self):
+        return None
+
+    async def __aexit__(self, _exc_type, _exc, _tb):
+        return False
+
+
+class FakeRevision:
+    def __init__(
+        self,
+        release_name: str,
+        release: object,
+        asfuid: str,
+        created: object,
+        phase: sql.ReleasePhase,
+        description: str | None,
+        use_check_cache: bool,
+    ):
+        self.asfuid = asfuid
+        self.created = created
+        self.description = description
+        self.name = ""
+        self.number = ""
+        self.parent_name: str | None = None
+        self.phase = phase
+        self.release = release
+        self.release_name = release_name
+        self.use_check_cache = use_check_cache
+
+
+class MockSafeData:
+    def __init__(self, parent_name: str):
+        self._new_revision: FakeRevision | None = None
+        self._parent_name = parent_name
+        self.add = mock.MagicMock(side_effect=self._add)
+        self.begin = mock.MagicMock(return_value=AsyncContextManager())
+        self.begin_immediate = mock.AsyncMock()
+        self.commit = mock.AsyncMock()
+        self.flush = mock.AsyncMock(side_effect=self._flush)
+        self.refresh = mock.AsyncMock()
+
+    def _add(self, new_revision: "FakeRevision") -> None:
+        self._new_revision = new_revision
+
+    async def _flush(self) -> None:
+        if self._new_revision is None:
+            raise RuntimeError("Expected data.add to set _new_revision before 
flush")
+        self._new_revision.name = f"{self._new_revision.release_name} 00006"
+        self._new_revision.number = "00006"
+        self._new_revision.parent_name = self._parent_name
+
+
+class MockSafeSession:
+    def __init__(self, data: MockSafeData):
+        self._data = data
+
+    async def __aenter__(self) -> MockSafeData:
+        return self._data
+
+    async def __aexit__(self, _exc_type, _exc, _tb):
+        return False
+
+
[email protected]
+async def 
test_clone_from_older_revision_skips_merge_without_intervening_change(tmp_path: 
pathlib.Path):
+    release = mock.MagicMock()
+    release.phase = sql.ReleasePhase.RELEASE_PREVIEW
+    release.project = mock.MagicMock()
+    release.project.release_policy = None
+    release.release_policy = None
+    release_name = sql.release_name("proj", "1.0")
+
+    latest_revision = mock.MagicMock()
+    latest_revision.name = f"{release_name} 00005"
+    latest_revision.number = "00005"
+
+    selected_revision = mock.MagicMock()
+    selected_revision.name = f"{release_name} 00002"
+    selected_revision.number = "00002"
+
+    mock_session = _mock_db_session(release, 
selected_revision=selected_revision)
+    participant = _make_participant()
+    safe_data = MockSafeData(parent_name=latest_revision.name)
+    merge_mock = mock.AsyncMock()
+
+    with (
+        mock.patch.object(revision.aiofiles.os, "makedirs", 
new_callable=mock.AsyncMock),
+        mock.patch.object(revision.aiofiles.os, "rename", 
new_callable=mock.AsyncMock),
+        mock.patch.object(
+            revision.attestable, "load", new_callable=mock.AsyncMock, 
return_value=mock.MagicMock(paths={})
+        ),
+        mock.patch.object(
+            revision.attestable, "paths_to_hashes_and_sizes", 
new_callable=mock.AsyncMock, return_value=({}, {})
+        ),
+        mock.patch.object(revision.attestable, "write_files_data", 
new_callable=mock.AsyncMock),
+        mock.patch.object(revision.db, "session", return_value=mock_session),
+        mock.patch.object(revision.detection, "validate_directory", 
return_value=[]),
+        mock.patch.object(
+            revision.interaction, "latest_revision", 
new_callable=mock.AsyncMock, return_value=latest_revision
+        ),
+        mock.patch.object(revision.merge, "merge", new=merge_mock),
+        mock.patch.object(revision.sql, "Revision", 
side_effect=_make_fake_revision),
+        mock.patch.object(revision, "SafeSession", 
return_value=MockSafeSession(safe_data)),
+        mock.patch.object(revision.tasks, "draft_checks", 
new_callable=mock.AsyncMock),
+        mock.patch.object(revision.util, "chmod_directories"),
+        mock.patch.object(revision.util, "chmod_files"),
+        mock.patch.object(
+            revision.util, "create_hard_link_clone", 
new_callable=mock.AsyncMock
+        ) as create_hard_link_clone_mock,
+        mock.patch.object(revision.util, "get_tmp_dir", return_value=tmp_path),
+        mock.patch.object(revision.util, "paths_to_inodes", return_value={}) 
as paths_to_inodes_mock,
+        mock.patch.object(revision.util, "release_directory", 
return_value=tmp_path / "releases" / "00006"),
+        mock.patch.object(revision.util, "release_directory_base", 
return_value=tmp_path / "releases"),
+    ):
+        await participant.create_revision("proj", "1.0", "test", 
clone_from="00002")
+
+    if merge_mock.called:
+        raise AssertionError(
+            "Expected create_revision(clone_from=...) to skip merge when no 
concurrent revision exists"
+        )
+    if create_hard_link_clone_mock.await_count != 1:
+        raise AssertionError("Expected one hard-link clone from clone_from 
source revision")
+    if paths_to_inodes_mock.call_count != 1:
+        raise AssertionError("Expected one paths_to_inodes call when 
clone_from disables merge")
+
+    clone_await_args = create_hard_link_clone_mock.await_args
+    inodes_call_args = paths_to_inodes_mock.call_args
+    if clone_await_args is None:
+        raise AssertionError("Expected create_hard_link_clone await args")
+    if inodes_call_args is None:
+        raise AssertionError("Expected paths_to_inodes call args")
+
+    clone_source = clone_await_args.args[0]
+    clone_destination = clone_await_args.args[1]
+    clone_kwargs = clone_await_args.kwargs
+    inodes_input = inodes_call_args.args[0]
+    expected_clone_source = tmp_path / "releases" / "00002"
+
+    if clone_source != expected_clone_source:
+        raise AssertionError("Expected hard-link clone source to match 
clone_from revision directory")
+    if clone_kwargs.get("do_not_create_dest_dir") is not True:
+        raise AssertionError("Expected hard-link clone to use 
do_not_create_dest_dir=True")
+    if clone_destination != inodes_input:
+        raise AssertionError("Expected inode scan to run only for the 
temporary working directory")
+
+
 @pytest.mark.asyncio
 async def test_modify_failed_error_propagates_and_cleans_up(tmp_path: 
pathlib.Path):
     received_args: dict[str, object] = {}
@@ -51,17 +199,24 @@ async def 
test_modify_failed_error_propagates_and_cleans_up(tmp_path: pathlib.Pa
     assert not os.listdir(tmp_path)
 
 
+def _make_fake_revision(**kwargs) -> FakeRevision:
+    return FakeRevision(**kwargs)
+
+
 def _make_participant() -> revision.CommitteeParticipant:
     mock_write = mock.MagicMock()
     mock_write.authorisation.asf_uid = "test"
     return revision.CommitteeParticipant(mock_write, mock.MagicMock(), 
mock.MagicMock(), "test")
 
 
-def _mock_db_session(release: mock.MagicMock) -> mock.MagicMock:
+def _mock_db_session(release: mock.MagicMock, selected_revision: 
mock.MagicMock | None = None) -> mock.MagicMock:
     mock_query = mock.MagicMock()
     mock_query.demand = mock.AsyncMock(return_value=release)
+    mock_selected_query = mock.MagicMock()
+    mock_selected_query.demand = mock.AsyncMock(return_value=selected_revision)
     mock_data = mock.AsyncMock()
     mock_data.release = mock.MagicMock(return_value=mock_query)
+    mock_data.revision = mock.MagicMock(return_value=mock_selected_query)
     mock_data.__aenter__ = mock.AsyncMock(return_value=mock_data)
     mock_data.__aexit__ = mock.AsyncMock(return_value=False)
     return mock_data


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to