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

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new 44e29e10 feat: add CherryPickOperation and wire 
SnapshotManager::Cherrypick (#926)
44e29e10 is described below

commit 44e29e109e461e7c9069855c9a90442002580d23
Author: Xinli Shang <[email protected]>
AuthorDate: Sun Oct 11 09:30:30 2026 +0900

    feat: add CherryPickOperation and wire SnapshotManager::Cherrypick (#926)
    
    Adds `CherryPickOperation` and implements `SnapshotManager::Cherrypick()`, 
which was a TODO stub.
    
    Follows Java's `CherryPickOperation`: appends re-apply their added files, 
dynamic overwrites (`replace-partitions`) re-apply adds and deletes and are 
rejected if a replaced partition changed, and anything else can only be 
fast-forward. Records `source-snapshot-id` and `published-wap-id`, and rejects 
a WAP id that was already published.
    
    Like Java, the operation is reachable only through `Transaction`, not 
`Table`.
    
    Fast-forwards also follow Java: `Apply()` returns the picked snapshot 
itself when it is a child of the current state, so no new snapshot is created. 
Because that decision is re-evaluated on every apply, a commit retry against 
moved metadata replays the pick on top of the new state instead of being 
rejected.
    
    Tests ported from Java `TestSnapshotManager` and `TestWapWorkflow`, plus 
regression coverage for commit retries and the fast-forward/replay transitions.
    
    Part of #637.
---
 src/iceberg/CMakeLists.txt                     |   1 +
 src/iceberg/cpp20_compatibility_internal.h     |   1 +
 src/iceberg/test/CMakeLists.txt                |   1 +
 src/iceberg/test/cherry_pick_operation_test.cc | 805 +++++++++++++++++++++++++
 src/iceberg/test/snapshot_util_test.cc         |  11 +
 src/iceberg/transaction.cc                     |  13 +-
 src/iceberg/transaction.h                      |   4 +
 src/iceberg/type_fwd.h                         |   1 +
 src/iceberg/update/cherry_pick_operation.cc    | 363 +++++++++++
 src/iceberg/update/cherry_pick_operation.h     |  85 +++
 src/iceberg/update/snapshot_manager.cc         |   7 +-
 src/iceberg/update/snapshot_manager.h          |   7 +-
 src/iceberg/update/snapshot_update.h           |   3 +-
 src/iceberg/util/snapshot_util.cc              |   8 +-
 14 files changed, 1302 insertions(+), 8 deletions(-)

diff --git a/src/iceberg/CMakeLists.txt b/src/iceberg/CMakeLists.txt
index b5fb4874..7394ce82 100644
--- a/src/iceberg/CMakeLists.txt
+++ b/src/iceberg/CMakeLists.txt
@@ -119,6 +119,7 @@ set(ICEBERG_SOURCES
     transform.cc
     transform_function.cc
     type.cc
+    update/cherry_pick_operation.cc
     update/delete_files.cc
     update/expire_snapshots.cc
     update/fast_append.cc
diff --git a/src/iceberg/cpp20_compatibility_internal.h 
b/src/iceberg/cpp20_compatibility_internal.h
index 5c54b544..8c4a465f 100644
--- a/src/iceberg/cpp20_compatibility_internal.h
+++ b/src/iceberg/cpp20_compatibility_internal.h
@@ -139,6 +139,7 @@
 #include "iceberg/transform_function.h"
 #include "iceberg/type.h"
 #include "iceberg/type_fwd.h"
+#include "iceberg/update/cherry_pick_operation.h"
 #include "iceberg/update/delete_files.h"
 #include "iceberg/update/expire_snapshots.h"
 #include "iceberg/update/fast_append.h"
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index 956a0cbd..058a9dd3 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -234,6 +234,7 @@ if(ICEBERG_BUILD_BUNDLE)
   add_iceberg_test(table_update_test
                    USE_BUNDLE
                    SOURCES
+                   cherry_pick_operation_test.cc
                    delete_files_test.cc
                    expire_snapshots_test.cc
                    fast_append_test.cc
diff --git a/src/iceberg/test/cherry_pick_operation_test.cc 
b/src/iceberg/test/cherry_pick_operation_test.cc
new file mode 100644
index 00000000..cb55de9d
--- /dev/null
+++ b/src/iceberg/test/cherry_pick_operation_test.cc
@@ -0,0 +1,805 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/update/cherry_pick_operation.h"
+
+#include <format>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include "iceberg/avro/avro_register.h"
+#include "iceberg/manifest/manifest_entry.h"
+#include "iceberg/manifest/manifest_reader.h"
+#include "iceberg/partition_spec.h"
+#include "iceberg/row/partition_values.h"
+#include "iceberg/schema.h"
+#include "iceberg/snapshot.h"
+#include "iceberg/table.h"
+#include "iceberg/table_metadata.h"
+#include "iceberg/table_properties.h"
+#include "iceberg/test/matchers.h"
+#include "iceberg/test/update_test_base.h"
+#include "iceberg/transaction.h"
+#include "iceberg/update/delete_files.h"
+#include "iceberg/update/fast_append.h"
+#include "iceberg/update/overwrite_files.h"
+#include "iceberg/update/replace_partitions.h"
+#include "iceberg/update/snapshot_manager.h"
+#include "iceberg/update/update_partition_spec.h"
+#include "iceberg/update/update_properties.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg {
+
+class CherryPickOperationTest : public UpdateTestBase {
+ public:
+  static void SetUpTestSuite() { avro::RegisterAll(); }
+
+ protected:
+  /// Most cherry-pick behavior is version independent; only tests that assert
+  /// version-dependent metadata derive from CherryPickFormatVersionTest.
+  virtual int8_t format_version() const { return 2; }
+
+  std::string MetadataResource() const override {
+    return format_version() == 3 ? "TableMetadataV3ValidMinimal.json"
+                                 : "TableMetadataV2ValidMinimal.json";
+  }
+
+  void SetUp() override {
+    UpdateTestBase::SetUp();
+    if (format_version() == 1) {
+      auto metadata = std::make_shared<TableMetadata>(*table_->metadata());
+      metadata->format_version = 1;
+      metadata->last_sequence_number = 0;
+      const auto metadata_location =
+          std::format("{}/metadata/v1.metadata.json", table_location_);
+      ASSERT_THAT(TableMetadataUtil::Write(*file_io_, metadata_location, 
*metadata),
+                  IsOk());
+      ASSERT_THAT(catalog_->DropTable(table_ident_, /*purge=*/false), IsOk());
+      ICEBERG_UNWRAP_OR_FAIL(table_,
+                             catalog_->RegisterTable(table_ident_, 
metadata_location));
+    }
+
+    ICEBERG_UNWRAP_OR_FAIL(spec_, table_->spec());
+    ICEBERG_UNWRAP_OR_FAIL(schema_, table_->schema());
+
+    file_a_ = MakeDataFile("/data/file_a.parquet", /*partition_x=*/1L);
+    file_b_ = MakeDataFile("/data/file_b.parquet", /*partition_x=*/2L);
+    replacement_a_ = MakeDataFile("/data/file_a_replacement.parquet", 
/*partition_x=*/1L);
+    conflict_a_ = MakeDataFile("/data/file_a_conflict.parquet", 
/*partition_x=*/1L);
+  }
+
+  std::shared_ptr<DataFile> MakeDataFile(const std::string& path, int64_t 
partition_x) {
+    auto f = std::make_shared<DataFile>();
+    f->content = DataFile::Content::kData;
+    f->file_path = table_location_ + path;
+    f->file_format = FileFormatType::kParquet;
+    f->partition = 
PartitionValues(std::vector<Literal>{Literal::Long(partition_x)});
+    f->file_size_in_bytes = 1024;
+    f->record_count = 100;
+    f->partition_spec_id = spec_->spec_id();
+    return f;
+  }
+
+  Result<std::vector<ManifestEntry>> LiveDataEntries(const Snapshot& snapshot) 
{
+    std::vector<ManifestEntry> entries;
+    SnapshotReader snapshot_reader(&snapshot);
+    ICEBERG_ASSIGN_OR_RAISE(auto manifests, 
snapshot_reader.DataManifests(file_io_));
+    for (const auto& manifest : manifests) {
+      ICEBERG_ASSIGN_OR_RAISE(
+          auto spec, 
table_->metadata()->PartitionSpecById(manifest.partition_spec_id));
+      ICEBERG_ASSIGN_OR_RAISE(auto reader,
+                              ManifestReader::Make(manifest, file_io_, 
schema_, spec));
+      ICEBERG_ASSIGN_OR_RAISE(auto live, reader->LiveEntries());
+      entries.insert(entries.end(), live.begin(), live.end());
+    }
+    return entries;
+  }
+
+  Result<std::vector<std::string>> LiveDataFilePaths() {
+    ICEBERG_ASSIGN_OR_RAISE(auto snapshot, table_->current_snapshot());
+    ICEBERG_ASSIGN_OR_RAISE(auto entries, LiveDataEntries(*snapshot));
+    std::vector<std::string> paths;
+    for (const auto& entry : entries) {
+      paths.push_back(entry.data_file->file_path);
+    }
+    return paths;
+  }
+
+  Result<int64_t> CommitAppend(const std::shared_ptr<DataFile>& file) {
+    ICEBERG_ASSIGN_OR_RAISE(auto append, table_->NewFastAppend());
+    append->AppendFile(file);
+    ICEBERG_RETURN_UNEXPECTED(append->Commit());
+    ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
+    ICEBERG_ASSIGN_OR_RAISE(auto snapshot, table_->current_snapshot());
+    return snapshot->snapshot_id;
+  }
+
+  Result<int64_t> CommitAppendToBranch(const std::string& branch,
+                                       const std::shared_ptr<DataFile>& file) {
+    ICEBERG_ASSIGN_OR_RAISE(auto append, table_->NewFastAppend());
+    // ToBranch() returns SnapshotUpdate& in C++20 compat builds, so keep the
+    // FastAppend-only call separate.
+    append->ToBranch(branch);
+    append->AppendFile(file);
+    ICEBERG_RETURN_UNEXPECTED(append->Commit());
+    ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
+    return table_->metadata()->refs.at(branch)->snapshot_id;
+  }
+
+  Result<int64_t> StageAppend(const std::shared_ptr<DataFile>& file,
+                              const std::optional<std::string>& wap_id = 
std::nullopt) {
+    ICEBERG_ASSIGN_OR_RAISE(auto append, table_->NewFastAppend());
+    append->StageOnly();
+    if (wap_id.has_value()) {
+      append->Set(SnapshotSummaryFields::kWAPId, *wap_id);
+    }
+    append->AppendFile(file);
+    ICEBERG_RETURN_UNEXPECTED(append->Commit());
+    ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
+    return table_->metadata()->snapshots.back()->snapshot_id;
+  }
+
+  Result<int64_t> StageReplacePartitions(const std::shared_ptr<DataFile>& 
file) {
+    ICEBERG_ASSIGN_OR_RAISE(auto transaction, table_->NewTransaction());
+    ICEBERG_ASSIGN_OR_RAISE(auto replace, transaction->NewReplacePartitions());
+    replace->StageOnly();
+    replace->Set(SnapshotSummaryFields::kReplacePartitions, "true");
+    replace->AddFile(file);
+    ICEBERG_RETURN_UNEXPECTED(replace->Commit());
+    ICEBERG_RETURN_UNEXPECTED(transaction->Commit());
+    ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
+    return table_->metadata()->snapshots.back()->snapshot_id;
+  }
+
+  Result<int64_t> StageOverwrite(const std::shared_ptr<DataFile>& added,
+                                 const std::shared_ptr<DataFile>& removed) {
+    ICEBERG_ASSIGN_OR_RAISE(auto transaction, table_->NewTransaction());
+    ICEBERG_ASSIGN_OR_RAISE(auto overwrite, transaction->NewOverwrite());
+    overwrite->StageOnly();
+    overwrite->AddFile(added).DeleteFile(removed);
+    ICEBERG_RETURN_UNEXPECTED(overwrite->Commit());
+    ICEBERG_RETURN_UNEXPECTED(transaction->Commit());
+    ICEBERG_RETURN_UNEXPECTED(table_->Refresh());
+    return table_->metadata()->snapshots.back()->snapshot_id;
+  }
+
+  Status RollbackTo(int64_t snapshot_id) {
+    ICEBERG_ASSIGN_OR_RAISE(auto manager, table_->NewSnapshotManager());
+    manager->RollbackTo(snapshot_id);
+    ICEBERG_RETURN_UNEXPECTED(manager->Commit());
+    return table_->Refresh();
+  }
+
+  Status CommitDelete(const std::string& path) {
+    ICEBERG_ASSIGN_OR_RAISE(auto delete_files, table_->NewDeleteFiles());
+    delete_files->DeleteFile(path);
+    ICEBERG_RETURN_UNEXPECTED(delete_files->Commit());
+    return table_->Refresh();
+  }
+
+  Result<std::shared_ptr<Transaction>> StageCherrypick(int64_t snapshot_id) {
+    ICEBERG_ASSIGN_OR_RAISE(auto transaction, table_->NewTransaction());
+    ICEBERG_ASSIGN_OR_RAISE(auto manager, transaction->NewSnapshotManager());
+    manager->Cherrypick(snapshot_id);
+    ICEBERG_RETURN_UNEXPECTED(manager->Commit());
+    return transaction;
+  }
+
+  Status Cherrypick(int64_t snapshot_id) {
+    ICEBERG_ASSIGN_OR_RAISE(auto manager, table_->NewSnapshotManager());
+    manager->Cherrypick(snapshot_id);
+    ICEBERG_RETURN_UNEXPECTED(manager->Commit());
+    return table_->Refresh();
+  }
+
+  /// Commit an append through a second table handle while another transaction 
is still
+  /// open, so that transaction must rebase and replay at commit time.
+  Result<int64_t> CommitFromOtherHandle(const std::shared_ptr<DataFile>& file) 
{
+    ICEBERG_ASSIGN_OR_RAISE(auto other_table, 
catalog_->LoadTable(table_ident_));
+    ICEBERG_ASSIGN_OR_RAISE(auto append, other_table->NewFastAppend());
+    append->AppendFile(file);
+    ICEBERG_RETURN_UNEXPECTED(append->Commit());
+    ICEBERG_RETURN_UNEXPECTED(other_table->Refresh());
+    ICEBERG_ASSIGN_OR_RAISE(auto snapshot, other_table->current_snapshot());
+    return snapshot->snapshot_id;
+  }
+
+  /// A fast-forward publishes the staged snapshot itself: no new snapshot, no 
new
+  /// sequence number or row ids, and one new snapshot-log entry for it.
+  void ExpectFastForward(const std::shared_ptr<TableMetadata>& before,
+                         int64_t staged_id) {
+    ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+    EXPECT_EQ(snapshot->snapshot_id, staged_id);
+    EXPECT_EQ(table_->metadata()->snapshots.size(), before->snapshots.size());
+    EXPECT_EQ(table_->metadata()->last_sequence_number, 
before->last_sequence_number);
+    EXPECT_EQ(table_->metadata()->next_row_id, before->next_row_id);
+    ASSERT_EQ(table_->metadata()->snapshot_log.size(), 
before->snapshot_log.size() + 1);
+    EXPECT_EQ(table_->metadata()->snapshot_log.back().snapshot_id, staged_id);
+    
EXPECT_FALSE(snapshot->summary.contains(SnapshotSummaryFields::kSourceSnapshotId));
+    
EXPECT_FALSE(snapshot->summary.contains(SnapshotSummaryFields::kPublishedWAPId));
+  }
+
+  /// A pick replayed on top of a moved state writes a new snapshot that is 
linked to the
+  /// picked one and parented on the state it was applied to.
+  void ExpectReplayedPick(int64_t staged_id, int64_t parent_snapshot_id) {
+    ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+    EXPECT_NE(snapshot->snapshot_id, staged_id);
+    EXPECT_EQ(snapshot->parent_snapshot_id, parent_snapshot_id);
+    EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
+              std::to_string(staged_id));
+  }
+
+  std::shared_ptr<PartitionSpec> spec_;
+  std::shared_ptr<Schema> schema_;
+  std::shared_ptr<DataFile> file_a_;
+  std::shared_ptr<DataFile> file_b_;
+  std::shared_ptr<DataFile> replacement_a_;
+  std::shared_ptr<DataFile> conflict_a_;
+};
+
+class CherryPickFormatVersionTest : public CherryPickOperationTest,
+                                    public 
::testing::WithParamInterface<int8_t> {
+ protected:
+  int8_t format_version() const override { return GetParam(); }
+};
+
+// A staged dynamic overwrite is re-applied onto a state that moved on, so a 
new
+// snapshot is produced rather than a fast-forward.
+TEST_F(CherryPickOperationTest, CherryPickDynamicOverwrite) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, 
StageReplacePartitions(replacement_a_));
+  ASSERT_THAT(CommitAppend(file_b_), IsOk());
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_NE(snapshot->snapshot_id, staged_id);
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
+            std::to_string(staged_id));
+
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path,
+                                                    
replacement_a_->file_path));
+}
+
+// The same pick works when the staged overwrite has no parent snapshot.
+TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteWithoutParent) {
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, 
StageReplacePartitions(replacement_a_));
+  ASSERT_THAT(CommitAppend(file_b_), IsOk());
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_NE(snapshot->snapshot_id, staged_id);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path,
+                                                    
replacement_a_->file_path));
+}
+
+// A file added concurrently into a replaced partition blocks the pick.
+TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteConflict) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, 
StageReplacePartitions(replacement_a_));
+  ICEBERG_UNWRAP_OR_FAIL(auto last_snapshot_id, CommitAppend(conflict_a_));
+
+  EXPECT_THAT(
+      Cherrypick(staged_id),
+      ::testing::AllOf(
+          IsError(ErrorKind::kValidationFailed),
+          HasErrorMessage("Cannot cherry-pick replace partitions with changed 
partition: "
+                          "x=1")));
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->snapshot_id, last_snapshot_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(
+      live, ::testing::UnorderedElementsAre(file_a_->file_path, 
conflict_a_->file_path));
+}
+
+// A file the staged overwrite removed must still be present at pick time.
+TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteDeleteConflict) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, 
StageReplacePartitions(replacement_a_));
+  ASSERT_THAT(CommitAppend(file_b_), IsOk());
+  ASSERT_THAT(CommitDelete(file_a_->file_path), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto before, table_->current_snapshot());
+  int64_t last_snapshot_id = before->snapshot_id;
+
+  EXPECT_THAT(Cherrypick(staged_id), IsError(ErrorKind::kValidationFailed));
+
+  ASSERT_THAT(table_->Refresh(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->snapshot_id, last_snapshot_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path));
+}
+
+TEST_P(CherryPickFormatVersionTest, CherryPickAppend) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_));
+  ICEBERG_UNWRAP_OR_FAIL(auto staged, table_->SnapshotById(staged_id));
+  ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
+  const auto before = table_->metadata();
+  ICEBERG_UNWRAP_OR_FAIL(auto parent, table_->current_snapshot());
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_NE(snapshot->snapshot_id, staged_id);
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
+            std::to_string(staged_id));
+  
EXPECT_FALSE(snapshot->summary.contains(SnapshotSummaryFields::kPublishedWAPId));
+  EXPECT_EQ(snapshot->parent_snapshot_id, parent->snapshot_id);
+  const int64_t expected_sequence =
+      GetParam() == 1 ? 0 : before->last_sequence_number + 1;
+  EXPECT_EQ(snapshot->sequence_number, expected_sequence);
+  EXPECT_EQ(table_->metadata()->last_sequence_number, expected_sequence);
+  EXPECT_EQ(table_->metadata()->snapshots.size(), before->snapshots.size() + 
1);
+  ASSERT_EQ(table_->metadata()->snapshot_log.size(), 
before->snapshot_log.size() + 1);
+  EXPECT_EQ(table_->metadata()->snapshot_log.back().snapshot_id, 
snapshot->snapshot_id);
+  ExpectBranch(std::string(SnapshotRef::kMainBranch), snapshot->snapshot_id);
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kAddedDataFiles), "1");
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kTotalDataFiles), "3");
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kTotalRecords), "300");
+
+  ICEBERG_UNWRAP_OR_FAIL(auto entries, LiveDataEntries(*snapshot));
+  ASSERT_EQ(entries.size(), 3);
+  int added_files = 0;
+  for (const auto& entry : entries) {
+    if (entry.data_file->file_path == file_b_->file_path) {
+      ++added_files;
+      EXPECT_EQ(entry.status, ManifestStatus::kAdded);
+      EXPECT_EQ(entry.snapshot_id, snapshot->snapshot_id);
+      EXPECT_EQ(entry.sequence_number, expected_sequence);
+      EXPECT_EQ(entry.file_sequence_number, expected_sequence);
+      if (GetParam() == 3) {
+        EXPECT_EQ(entry.data_file->first_row_id, before->next_row_id);
+        EXPECT_NE(entry.data_file->first_row_id, staged->first_row_id);
+      } else {
+        EXPECT_FALSE(entry.data_file->first_row_id.has_value());
+      }
+    }
+  }
+  EXPECT_EQ(added_files, 1);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(
+                        file_a_->file_path, file_b_->file_path, 
conflict_a_->file_path));
+  if (GetParam() == 3) {
+    EXPECT_EQ(snapshot->first_row_id, before->next_row_id);
+    EXPECT_EQ(snapshot->added_rows, file_b_->record_count);
+    EXPECT_EQ(table_->metadata()->next_row_id,
+              before->next_row_id + file_b_->record_count);
+  }
+}
+
+// Main is empty, but the source snapshot has a parent on another branch.
+TEST_F(CherryPickOperationTest, CherryPickAppendOntoEmptyMain) {
+  ASSERT_THAT(CommitAppendToBranch("b1", file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, CommitAppendToBranch("b1", file_b_));
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_NE(snapshot->snapshot_id, staged_id);
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
+            std::to_string(staged_id));
+
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path));
+}
+
+// When the picked snapshot's parent is the current snapshot, the pick moves 
the
+// current snapshot instead of creating one.
+// When the picked snapshot's parent is the current snapshot, the pick moves 
the
+// current snapshot instead of creating one.
+TEST_F(CherryPickOperationTest, FastForwardSetsCurrentSnapshot) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_));
+  const auto before = table_->metadata();
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ExpectFastForward(before, staged_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live,
+              ::testing::UnorderedElementsAre(file_a_->file_path, 
file_b_->file_path));
+}
+
+// An overwrite without "replace-partitions" is not pickable, but it is still
+// fast-forwarded to when it is a child of the current snapshot.
+// An overwrite without "replace-partitions" is not pickable, but it is still
+// fast-forwarded to when it is a child of the current snapshot.
+TEST_F(CherryPickOperationTest, FastForwardOverwriteSetsCurrentSnapshot) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id,
+                         StageOverwrite(/*added=*/replacement_a_, 
/*removed=*/file_a_));
+  const auto before = table_->metadata();
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ExpectFastForward(before, staged_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kOperation),
+            DataOperation::kOverwrite);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, 
::testing::UnorderedElementsAre(replacement_a_->file_path));
+}
+
+// A dynamic overwrite whose parent has been rolled off the current history
+// cannot be picked, because the partitions it replaced cannot be checked.
+TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteParentNotAncestor) {
+  ICEBERG_UNWRAP_OR_FAIL(auto first_id, CommitAppend(file_a_));
+  ASSERT_THAT(CommitAppend(file_b_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, 
StageReplacePartitions(replacement_a_));
+  ASSERT_THAT(RollbackTo(first_id), IsOk());
+
+  EXPECT_THAT(Cherrypick(staged_id),
+              ::testing::AllOf(
+                  IsError(ErrorKind::kValidationFailed),
+                  HasErrorMessage(std::format("Cannot cherry-pick overwrite 
not based on "
+                                              "an ancestor of the current 
state: {}",
+                                              staged_id))));
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->snapshot_id, first_id);
+}
+
+// A WAP id already published by an ancestor is rejected even when the staged
+// snapshot is a child of the current one and would otherwise fast-forward.
+TEST_F(CherryPickOperationTest, DuplicateWapPublishOnFastForwardRejected) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto first_staged_id,
+                         StageAppend(file_b_, /*wap_id=*/"wap-123"));
+
+  ASSERT_THAT(Cherrypick(first_staged_id), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto picked, table_->current_snapshot());
+  EXPECT_EQ(picked->snapshot_id, first_staged_id);
+
+  // Staged on top of the snapshot just published, so this is a fast-forward.
+  ICEBERG_UNWRAP_OR_FAIL(auto second_staged_id,
+                         StageAppend(conflict_a_, /*wap_id=*/"wap-123"));
+
+  EXPECT_THAT(
+      Cherrypick(second_staged_id),
+      ::testing::AllOf(IsError(ErrorKind::kValidationFailed),
+                       HasErrorMessage("Duplicate request to cherry pick wap 
id that "
+                                       "was published already: wap-123")));
+
+  ASSERT_THAT(table_->Refresh(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->snapshot_id, first_staged_id);
+}
+
+TEST_F(CherryPickOperationTest, 
NonPickableFastForwardInvalidatedByConcurrentCommit) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageOverwrite(replacement_a_, 
file_a_));
+  ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
+  ICEBERG_UNWRAP_OR_FAIL(auto concurrent_id, CommitFromOtherHandle(file_b_));
+
+  EXPECT_THAT(transaction->Commit(),
+              ::testing::AllOf(
+                  IsError(ErrorKind::kValidationFailed),
+                  HasErrorMessage(std::format(
+                      "Cannot cherry-pick snapshot {}: not append, dynamic 
overwrite, "
+                      "or fast-forward",
+                      staged_id))));
+  ASSERT_THAT(table_->Refresh(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->snapshot_id, concurrent_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live,
+              ::testing::UnorderedElementsAre(file_a_->file_path, 
file_b_->file_path));
+}
+
+// A snapshot already in the current history cannot be picked again.
+TEST_F(CherryPickOperationTest, CherryPickAncestorRejected) {
+  ICEBERG_UNWRAP_OR_FAIL(auto first_id, CommitAppend(file_a_));
+  ASSERT_THAT(CommitAppend(file_b_), IsOk());
+
+  EXPECT_THAT(Cherrypick(first_id),
+              ::testing::AllOf(
+                  IsError(ErrorKind::kValidationFailed),
+                  HasErrorMessage(std::format(
+                      "Cannot cherrypick snapshot {}: already an ancestor", 
first_id))));
+
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live,
+              ::testing::UnorderedElementsAre(file_a_->file_path, 
file_b_->file_path));
+}
+
+// The same WAP id cannot be picked twice.
+TEST_F(CherryPickOperationTest, DuplicateWapPublishRejected) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto first_staged_id,
+                         StageAppend(file_b_, /*wap_id=*/"wap-123"));
+  ICEBERG_UNWRAP_OR_FAIL(auto second_staged_id,
+                         StageAppend(conflict_a_, /*wap_id=*/"wap-123"));
+
+  ASSERT_THAT(Cherrypick(first_staged_id), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto picked, table_->current_snapshot());
+  EXPECT_EQ(picked->snapshot_id, first_staged_id);
+
+  EXPECT_THAT(
+      Cherrypick(second_staged_id),
+      ::testing::AllOf(IsError(ErrorKind::kValidationFailed),
+                       HasErrorMessage("Duplicate request to cherry pick wap 
id that "
+                                       "was published already: wap-123")));
+}
+
+// A snapshot that is neither an append nor a dynamic overwrite can only be
+// fast-forwarded.
+TEST_F(CherryPickOperationTest, NonPickableOperationRejected) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ASSERT_THAT(CommitAppend(file_b_), IsOk());
+
+  auto delete_files = table_->NewDeleteFiles();
+  ASSERT_TRUE(delete_files.has_value());
+  delete_files.value()->StageOnly();
+  delete_files.value()->DeleteFile(file_a_->file_path);
+  ASSERT_THAT(delete_files.value()->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+  int64_t staged_id = table_->metadata()->snapshots.back()->snapshot_id;
+
+  ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
+
+  EXPECT_THAT(Cherrypick(staged_id),
+              ::testing::AllOf(
+                  IsError(ErrorKind::kValidationFailed),
+                  HasErrorMessage(std::format(
+                      "Cannot cherry-pick snapshot {}: not append, dynamic 
overwrite, "
+                      "or fast-forward",
+                      staged_id))));
+}
+
+TEST_F(CherryPickOperationTest, UnknownSnapshotRejected) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+
+  EXPECT_THAT(
+      Cherrypick(/*snapshot_id=*/-99),
+      ::testing::AllOf(IsError(ErrorKind::kValidationFailed),
+                       HasErrorMessage("Cannot cherry-pick unknown snapshot 
ID: -99")));
+}
+
+TEST_F(CherryPickOperationTest, FastForwardOntoEmptyMain) {
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_a_, "wap-123"));
+  const auto before = table_->metadata();
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ExpectFastForward(before, staged_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kWAPId), "wap-123");
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::ElementsAre(file_a_->file_path));
+}
+
+TEST_F(CherryPickOperationTest, 
FastForwardAppendReplayedAfterConcurrentAppend) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_, "wap-123"));
+  ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
+  ICEBERG_UNWRAP_OR_FAIL(auto concurrent_id, 
CommitFromOtherHandle(conflict_a_));
+
+  ASSERT_THAT(transaction->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  ExpectReplayedPick(staged_id, concurrent_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kPublishedWAPId), 
"wap-123");
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(
+                        file_a_->file_path, file_b_->file_path, 
conflict_a_->file_path));
+}
+
+TEST_F(CherryPickOperationTest, 
FastForwardDynamicOverwriteReplayedWithoutConflict) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, 
StageReplacePartitions(replacement_a_));
+  ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
+  ICEBERG_UNWRAP_OR_FAIL(auto concurrent_id, CommitFromOtherHandle(file_b_));
+
+  ASSERT_THAT(transaction->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  ExpectReplayedPick(staged_id, concurrent_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->Operation(), DataOperation::kOverwrite);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(file_b_->file_path,
+                                                    
replacement_a_->file_path));
+}
+
+TEST_F(CherryPickOperationTest, 
FastForwardDynamicOverwriteReplayedWithConflict) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, 
StageReplacePartitions(replacement_a_));
+  ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
+  ICEBERG_UNWRAP_OR_FAIL(auto concurrent_id, 
CommitFromOtherHandle(conflict_a_));
+
+  EXPECT_THAT(transaction->Commit(),
+              ::testing::AllOf(
+                  IsError(ErrorKind::kValidationFailed),
+                  HasErrorMessage("Cannot cherry-pick replace partitions with 
changed "
+                                  "partition")));
+  ASSERT_THAT(table_->Refresh(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->snapshot_id, concurrent_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(
+      live, ::testing::UnorderedElementsAre(file_a_->file_path, 
conflict_a_->file_path));
+}
+
+TEST_F(CherryPickOperationTest, CherryPickReplayedAsFastForwardAfterRollback) {
+  ICEBERG_UNWRAP_OR_FAIL(auto parent_id, CommitAppend(file_a_));
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_));
+  ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(staged_id));
+  ICEBERG_UNWRAP_OR_FAIL(auto draft, transaction->current().Snapshot());
+  ASSERT_NE(draft->snapshot_id, staged_id);
+  ASSERT_THAT(file_io_->ReadFile(draft->manifest_list, std::nullopt), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto other_table, catalog_->LoadTable(table_ident_));
+  ICEBERG_UNWRAP_OR_FAIL(auto rollback, other_table->NewSnapshotManager());
+  rollback->RollbackTo(parent_id);
+  ASSERT_THAT(rollback->Commit(), IsOk());
+  ASSERT_THAT(other_table->Refresh(), IsOk());
+  const auto before = other_table->metadata();
+
+  ASSERT_THAT(transaction->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  ExpectFastForward(before, staged_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_THAT(file_io_->ReadFile(draft->manifest_list, std::nullopt),
+              IsError(ErrorKind::kIOError));
+  EXPECT_THAT(file_io_->ReadFile(snapshot->manifest_list, std::nullopt), 
IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live,
+              ::testing::UnorderedElementsAre(file_a_->file_path, 
file_b_->file_path));
+}
+
+TEST_F(CherryPickOperationTest, FastForwardSucceedsAfterCommitRetries) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_, "wap-123"));
+  const auto before = table_->metadata();
+  FailCommits(2);
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ExpectFastForward(before, staged_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live,
+              ::testing::UnorderedElementsAre(file_a_->file_path, 
file_b_->file_path));
+}
+
+TEST_F(CherryPickOperationTest, CherryPickRecordsPublishedWapId) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_, "wap-123"));
+  ICEBERG_UNWRAP_OR_FAIL(auto second_staged_id, StageAppend(replacement_a_, 
"wap-123"));
+  ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_NE(snapshot->snapshot_id, staged_id);
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
+            std::to_string(staged_id));
+  EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kPublishedWAPId), 
"wap-123");
+  EXPECT_FALSE(snapshot->summary.contains(SnapshotSummaryFields::kWAPId));
+  EXPECT_THAT(Cherrypick(second_staged_id),
+              HasErrorMessage("Duplicate request to cherry pick wap id"));
+  ASSERT_THAT(table_->Refresh(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto after, table_->current_snapshot());
+  EXPECT_EQ(after->snapshot_id, snapshot->snapshot_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(
+                        file_a_->file_path, file_b_->file_path, 
conflict_a_->file_path));
+
+  // An explicitly empty wap id is still recorded as published-wap-id.
+  auto extra = MakeDataFile("/data/file_extra.parquet", /*partition_x=*/4L);
+  auto bridge = MakeDataFile("/data/file_bridge.parquet", /*partition_x=*/5L);
+  ICEBERG_UNWRAP_OR_FAIL(auto empty_wap_id, StageAppend(extra, /*wap_id=*/""));
+  ASSERT_THAT(CommitAppend(bridge), IsOk());
+
+  ASSERT_THAT(Cherrypick(empty_wap_id), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto empty_wap_snapshot, table_->current_snapshot());
+  
EXPECT_EQ(empty_wap_snapshot->summary.at(SnapshotSummaryFields::kPublishedWAPId),
 "");
+  
EXPECT_EQ(empty_wap_snapshot->summary.at(SnapshotSummaryFields::kSourceSnapshotId),
+            std::to_string(empty_wap_id));
+}
+
+TEST_F(CherryPickOperationTest, DuplicateSourceSnapshotRejected) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, StageAppend(file_b_));
+  ASSERT_THAT(CommitAppend(conflict_a_), IsOk());
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto picked, table_->current_snapshot());
+
+  EXPECT_THAT(Cherrypick(staged_id),
+              ::testing::AllOf(
+                  IsError(ErrorKind::kValidationFailed),
+                  HasErrorMessage(std::format(
+                      "Cannot cherrypick snapshot {}: already picked to create 
ancestor "
+                      "{}",
+                      staged_id, picked->snapshot_id))));
+
+  ASSERT_THAT(table_->Refresh(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->snapshot_id, picked->snapshot_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live, ::testing::UnorderedElementsAre(
+                        file_a_->file_path, file_b_->file_path, 
conflict_a_->file_path));
+}
+
+TEST_F(CherryPickOperationTest, DuplicateWapPublishedDuringReplayRejected) {
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto first_id, StageAppend(file_b_, "wap-123"));
+  ICEBERG_UNWRAP_OR_FAIL(auto second_id, StageAppend(replacement_a_, 
"wap-123"));
+  ICEBERG_UNWRAP_OR_FAIL(auto transaction, StageCherrypick(second_id));
+
+  ICEBERG_UNWRAP_OR_FAIL(auto other_table, catalog_->LoadTable(table_ident_));
+  ICEBERG_UNWRAP_OR_FAIL(auto publish, other_table->NewSnapshotManager());
+  publish->Cherrypick(first_id);
+  ASSERT_THAT(publish->Commit(), IsOk());
+
+  EXPECT_THAT(
+      transaction->Commit(),
+      ::testing::AllOf(IsError(ErrorKind::kValidationFailed),
+                       HasErrorMessage("Duplicate request to cherry pick wap 
id")));
+  ASSERT_THAT(table_->Refresh(), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  EXPECT_EQ(snapshot->snapshot_id, first_id);
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(live,
+              ::testing::UnorderedElementsAre(file_a_->file_path, 
file_b_->file_path));
+}
+
+TEST_F(CherryPickOperationTest, CherryPickDynamicOverwriteFromMergedManifest) {
+  ICEBERG_UNWRAP_OR_FAIL(auto properties, table_->NewUpdateProperties());
+  properties->Set(TableProperties::kManifestMinMergeCount.key(), "1");
+  ASSERT_THAT(properties->Commit(), IsOk());
+  ASSERT_THAT(CommitAppend(file_a_), IsOk());
+  ASSERT_THAT(CommitAppend(file_b_), IsOk());
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_id, 
StageReplacePartitions(replacement_a_));
+  ICEBERG_UNWRAP_OR_FAIL(auto staged, table_->SnapshotById(staged_id));
+  ICEBERG_UNWRAP_OR_FAIL(auto staged_entries, LiveDataEntries(*staged));
+  ASSERT_THAT(staged_entries, ::testing::Contains(::testing::Field(
+                                  &ManifestEntry::status, 
ManifestStatus::kExisting)));
+  auto new_file = MakeDataFile("/data/another_partition.parquet", 3L);
+  ASSERT_THAT(CommitAppend(new_file), IsOk());
+
+  ASSERT_THAT(Cherrypick(staged_id), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto live, LiveDataFilePaths());
+  EXPECT_THAT(
+      live, ::testing::UnorderedElementsAre(file_b_->file_path, 
replacement_a_->file_path,
+                                            new_file->file_path));
+}
+
+INSTANTIATE_TEST_SUITE_P(FormatVersions, CherryPickFormatVersionTest,
+                         ::testing::Values(1, 2, 3),
+                         [](const ::testing::TestParamInfo<int8_t>& info) {
+                           return std::format("V{}", info.param);
+                         });
+
+}  // namespace iceberg
diff --git a/src/iceberg/test/snapshot_util_test.cc 
b/src/iceberg/test/snapshot_util_test.cc
index 25aa59ab..3b6204ff 100644
--- a/src/iceberg/test/snapshot_util_test.cc
+++ b/src/iceberg/test/snapshot_util_test.cc
@@ -222,6 +222,17 @@ TEST_F(SnapshotUtilTest, IsAncestorOf) {
   EXPECT_FALSE(result5);
 }
 
+TEST_F(SnapshotUtilTest, IsAncestorOfNoCurrentSnapshot) {
+  TableMetadata metadata;
+  metadata.current_snapshot_id = kInvalidSnapshotId;
+  metadata.snapshots = {
+      CreateSnapshot(base_snapshot_id_, std::nullopt, 1, base_timestamp_)};
+
+  ICEBERG_UNWRAP_OR_FAIL(auto result,
+                         SnapshotUtil::IsAncestorOf(metadata, 
base_snapshot_id_));
+  EXPECT_FALSE(result);
+}
+
 TEST_F(SnapshotUtilTest, CurrentAncestors) {
   ICEBERG_UNWRAP_OR_FAIL(auto ancestors, 
SnapshotUtil::CurrentAncestors(*table_));
   auto ids = ExtractSnapshotIds(ancestors);
diff --git a/src/iceberg/transaction.cc b/src/iceberg/transaction.cc
index 391d6f6c..0eaa3c97 100644
--- a/src/iceberg/transaction.cc
+++ b/src/iceberg/transaction.cc
@@ -35,6 +35,7 @@
 #include "iceberg/table_requirement.h"
 #include "iceberg/table_requirements.h"
 #include "iceberg/table_update.h"
+#include "iceberg/update/cherry_pick_operation.h"
 #include "iceberg/update/delete_files.h"
 #include "iceberg/update/expire_snapshots.h"
 #include "iceberg/update/fast_append.h"
@@ -356,11 +357,14 @@ Status Transaction::ApplyUpdateSnapshot(SnapshotUpdate& 
update) {
   const auto& base = ctx_->metadata_builder->current();
 
   ICEBERG_ASSIGN_OR_RAISE(auto result, update.Apply());
+  if (result.snapshot == nullptr) {
+    return {};
+  }
 
   // Create a temp builder to check if this is an empty update
   auto temp_update = TableMetadataBuilder::BuildFrom(&base);
   if (base.SnapshotById(result.snapshot->snapshot_id).has_value()) {
-    // This is a rollback operation
+    // Move the branch to an existing snapshot.
     temp_update->SetBranchSnapshot(result.snapshot->snapshot_id, 
result.target_branch);
   } else if (result.stage_only) {
     temp_update->AddSnapshot(result.snapshot);
@@ -635,6 +639,13 @@ Result<std::shared_ptr<UpdateLocation>> 
Transaction::NewUpdateLocation() {
   return update_location;
 }
 
+Result<std::shared_ptr<CherryPickOperation>> 
Transaction::NewCherryPickOperation() {
+  ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<CherryPickOperation> cherry_pick,
+                          CherryPickOperation::Make(ctx_->table->name().name, 
ctx_));
+  ICEBERG_RETURN_UNEXPECTED(AddUpdate(cherry_pick));
+  return cherry_pick;
+}
+
 Result<std::shared_ptr<SetSnapshot>> Transaction::NewSetSnapshot() {
   ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<SetSnapshot> set_snapshot,
                           SetSnapshot::Make(ctx_));
diff --git a/src/iceberg/transaction.h b/src/iceberg/transaction.h
index 75e3c831..47678b15 100644
--- a/src/iceberg/transaction.h
+++ b/src/iceberg/transaction.h
@@ -153,6 +153,10 @@ class ICEBERG_EXPORT Transaction : public 
std::enable_shared_from_this<Transacti
   /// \brief Create a new SnapshotManager to manage snapshots.
   Result<std::shared_ptr<SnapshotManager>> NewSnapshotManager();
 
+  /// \brief Create a new CherryPickOperation to apply the changes of an 
existing
+  /// snapshot onto the current state.
+  Result<std::shared_ptr<CherryPickOperation>> NewCherryPickOperation();
+
   /// \brief Create a new SetSnapshot to set the current snapshot or rollback 
to a
   /// previous snapshot and commit the changes.
   Result<std::shared_ptr<SetSnapshot>> NewSetSnapshot();
diff --git a/src/iceberg/type_fwd.h b/src/iceberg/type_fwd.h
index f45dd011..84a99190 100644
--- a/src/iceberg/type_fwd.h
+++ b/src/iceberg/type_fwd.h
@@ -250,6 +250,7 @@ class Transaction;
 class TransactionContext;
 
 /// \brief Update family.
+class CherryPickOperation;
 class DeleteFiles;
 class ExpireSnapshots;
 class FastAppend;
diff --git a/src/iceberg/update/cherry_pick_operation.cc 
b/src/iceberg/update/cherry_pick_operation.cc
new file mode 100644
index 00000000..d0a33076
--- /dev/null
+++ b/src/iceberg/update/cherry_pick_operation.cc
@@ -0,0 +1,363 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/update/cherry_pick_operation.h"
+
+#include <string>
+#include <vector>
+
+#include "iceberg/manifest/manifest_entry.h"
+#include "iceberg/manifest/manifest_list.h"
+#include "iceberg/manifest/manifest_reader.h"
+#include "iceberg/schema.h"
+#include "iceberg/snapshot.h"
+#include "iceberg/table.h"
+#include "iceberg/table_metadata.h"
+#include "iceberg/transaction.h"
+#include "iceberg/util/content_file_util.h"
+#include "iceberg/util/error_collector.h"
+#include "iceberg/util/macros.h"
+#include "iceberg/util/snapshot_util.h"
+#include "iceberg/util/string_util.h"
+
+namespace iceberg {
+
+namespace {
+
+Result<std::unique_ptr<ManifestReader>> MakeManifestReader(
+    const ManifestFile& manifest, const std::shared_ptr<FileIO>& file_io,
+    const TableMetadata& metadata) {
+  ICEBERG_ASSIGN_OR_RAISE(auto schema, metadata.Schema());
+  TableMetadataCache metadata_cache(&metadata);
+  ICEBERG_ASSIGN_OR_RAISE(auto specs_by_id, 
metadata_cache.GetPartitionSpecsById());
+  return ManifestReader::Make(manifest, file_io, std::move(schema), 
specs_by_id.get());
+}
+
+/// \brief Data files added and removed by a snapshot.
+struct SnapshotChanges {
+  std::vector<std::shared_ptr<DataFile>> added;
+  std::vector<std::shared_ptr<DataFile>> removed;
+};
+
+/// \brief Read the data files that the given snapshot added and removed.
+///
+/// Only manifests written by the snapshot carry its own entries, so manifests
+/// inherited from earlier snapshots are skipped.
+Result<SnapshotChanges> ReadSnapshotChanges(const Snapshot& snapshot,
+                                            const std::shared_ptr<FileIO>& 
file_io,
+                                            const TableMetadata& metadata) {
+  SnapshotChanges changes;
+  SnapshotReader snapshot_reader(&snapshot);
+  ICEBERG_ASSIGN_OR_RAISE(auto manifests, 
snapshot_reader.DataManifests(file_io));
+  for (const auto& manifest : manifests) {
+    if (manifest.added_snapshot_id != snapshot.snapshot_id) {
+      continue;
+    }
+    ICEBERG_ASSIGN_OR_RAISE(auto reader, MakeManifestReader(manifest, file_io, 
metadata));
+    ICEBERG_ASSIGN_OR_RAISE(auto entries, reader->EntriesStream());
+    while (true) {
+      ICEBERG_ASSIGN_OR_RAISE(auto entry, entries->Next());
+      if (!entry.has_value()) {
+        break;
+      }
+      if (!entry->data_file) {
+        continue;
+      }
+      if (entry->status == ManifestStatus::kAdded) {
+        changes.added.push_back(std::move(entry->data_file));
+      } else if (entry->status == ManifestStatus::kDeleted) {
+        ContentFileUtil::DropAllStats(*entry->data_file);
+        changes.removed.push_back(std::move(entry->data_file));
+      }
+    }
+  }
+  return changes;
+}
+
+std::optional<std::string> StagedWapId(const Snapshot& snapshot) {
+  auto it = snapshot.summary.find(SnapshotSummaryFields::kWAPId);
+  return it == snapshot.summary.end() ? std::nullopt : 
std::make_optional(it->second);
+}
+
+std::optional<std::string> PublishedWapId(const Snapshot& snapshot) {
+  auto it = snapshot.summary.find(SnapshotSummaryFields::kPublishedWAPId);
+  return it == snapshot.summary.end() ? std::nullopt : 
std::make_optional(it->second);
+}
+
+Result<std::vector<int64_t>> CurrentAncestorIds(const TableMetadata& metadata) 
{
+  std::vector<int64_t> ids;
+  if (metadata.current_snapshot_id == kInvalidSnapshotId) {
+    return ids;
+  }
+  ICEBERG_ASSIGN_OR_RAISE(
+      auto ancestors, SnapshotUtil::AncestorsOf(metadata, 
metadata.current_snapshot_id));
+  ids.reserve(ancestors.size());
+  for (const auto& ancestor : ancestors) {
+    ids.push_back(ancestor->snapshot_id);
+  }
+  return ids;
+}
+
+/// \brief Fail if the WAP id staged on the picked snapshot was already
+/// published, and return that id when the snapshot has one.
+Result<std::optional<std::string>> ValidateWapPublish(const TableMetadata& 
metadata,
+                                                      int64_t wap_snapshot_id) 
{
+  ICEBERG_ASSIGN_OR_RAISE(auto snapshot, 
metadata.SnapshotById(wap_snapshot_id));
+  auto wap_id = StagedWapId(*snapshot);
+  if (!wap_id.has_value() || wap_id->empty()) {
+    return wap_id;
+  }
+
+  ICEBERG_ASSIGN_OR_RAISE(auto ancestor_ids, CurrentAncestorIds(metadata));
+  for (int64_t ancestor_id : ancestor_ids) {
+    ICEBERG_ASSIGN_OR_RAISE(auto ancestor, metadata.SnapshotById(ancestor_id));
+    if (wap_id == StagedWapId(*ancestor) || wap_id == 
PublishedWapId(*ancestor)) {
+      return ValidationFailed(
+          "Duplicate request to cherry pick wap id that was published already: 
{}",
+          *wap_id);
+    }
+  }
+  return wap_id;
+}
+
+bool IsReplacePartitions(const Snapshot& snapshot) {
+  auto it = snapshot.summary.find(SnapshotSummaryFields::kReplacePartitions);
+  return it != snapshot.summary.end() &&
+         StringUtils::EqualsIgnoreCase(it->second, "true");
+}
+
+}  // namespace
+
+Result<std::unique_ptr<CherryPickOperation>> CherryPickOperation::Make(
+    std::string table_name, std::shared_ptr<TransactionContext> ctx) {
+  ICEBERG_PRECHECK(!table_name.empty(), "Table name cannot be empty");
+  ICEBERG_PRECHECK(ctx != nullptr, "Cannot create CherryPickOperation without 
a context");
+  return std::unique_ptr<CherryPickOperation>(
+      new CherryPickOperation(std::move(table_name), std::move(ctx)));
+}
+
+CherryPickOperation::CherryPickOperation(std::string table_name,
+                                         std::shared_ptr<TransactionContext> 
ctx)
+    : MergingSnapshotUpdate(std::move(table_name), std::move(ctx)) {}
+
+std::string CherryPickOperation::operation() {
+  ICEBERG_DCHECK(cherrypick_snapshot_ != nullptr, "Uninitialized cherry-pick 
operation");
+  return std::string(cherrypick_snapshot_->Operation().value());
+}
+
+bool CherryPickOperation::IsFastForward(const TableMetadata& base) const {
+  if (base.current_snapshot_id == kInvalidSnapshotId) {
+    return !cherrypick_snapshot_->parent_snapshot_id.has_value();
+  }
+  return cherrypick_snapshot_->parent_snapshot_id == base.current_snapshot_id;
+}
+
+Result<SnapshotUpdate::ApplyResult> CherryPickOperation::Apply() {
+  ICEBERG_RETURN_UNEXPECTED(CheckErrors());
+  const auto& base = this->base();
+  if (cherrypick_snapshot_ == nullptr) {
+    ICEBERG_ASSIGN_OR_RAISE(auto current,
+                            SnapshotUtil::OptionalLatestSnapshot(
+                                base, std::string(SnapshotRef::kMainBranch)));
+    return ApplyResult{.snapshot = std::move(current),
+                       .target_branch = std::string(SnapshotRef::kMainBranch)};
+  }
+
+  const bool is_fast_forward = IsFastForward(base);
+  if (require_fast_forward_ || is_fast_forward) {
+    ICEBERG_CHECK(
+        is_fast_forward,
+        "Cannot cherry-pick snapshot {}: not append, dynamic overwrite, or 
fast-forward",
+        cherrypick_snapshot_->snapshot_id);
+    ICEBERG_ASSIGN_OR_RAISE(auto snapshot,
+                            
base.SnapshotById(cherrypick_snapshot_->snapshot_id));
+    return ApplyResult{.snapshot = std::move(snapshot),
+                       .target_branch = std::string(SnapshotRef::kMainBranch)};
+  }
+  return SnapshotUpdate::Apply();
+}
+
+CherryPickOperation& CherryPickOperation::Cherrypick(int64_t snapshot_id) {
+  const TableMetadata& current = ctx_->current();
+  ICEBERG_BUILDER_ASSIGN_OR_RETURN_WITH_ERROR(
+      cherrypick_snapshot_, current.SnapshotById(snapshot_id),
+      "Cannot cherry-pick unknown snapshot ID: {}", snapshot_id);
+
+  const auto picked_operation = cherrypick_snapshot_->Operation();
+  const bool is_append = picked_operation == DataOperation::kAppend;
+  const bool is_dynamic_overwrite = picked_operation == 
DataOperation::kOverwrite &&
+                                    IsReplacePartitions(*cherrypick_snapshot_);
+
+  if (!is_append && !is_dynamic_overwrite) {
+    ICEBERG_BUILDER_CHECK(
+        IsFastForward(current),
+        "Cannot cherry-pick snapshot {}: not append, dynamic overwrite, or 
fast-forward",
+        snapshot_id);
+    require_fast_forward_ = true;
+    return *this;
+  }
+
+  if (is_dynamic_overwrite) {
+    // Partition conflicts are checked against files added since the parent 
snapshot.
+    // The parent must be a current ancestor, or absent if the overwrite was 
based on
+    // an empty table.
+    if (cherrypick_snapshot_->parent_snapshot_id.has_value()) {
+      ICEBERG_BUILDER_ASSIGN_OR_RETURN(
+          bool is_ancestor,
+          SnapshotUtil::IsAncestorOf(current,
+                                     
cherrypick_snapshot_->parent_snapshot_id.value()));
+      ICEBERG_BUILDER_CHECK(is_ancestor,
+                            "Cannot cherry-pick overwrite not based on an 
ancestor of "
+                            "the current state: {}",
+                            snapshot_id);
+    }
+  }
+
+  ICEBERG_BUILDER_ASSIGN_OR_RETURN(auto wap_id, ValidateWapPublish(current, 
snapshot_id));
+  if (wap_id.has_value()) {
+    Set(SnapshotSummaryFields::kPublishedWAPId, *wap_id);
+  }
+  Set(SnapshotSummaryFields::kSourceSnapshotId, std::to_string(snapshot_id));
+
+  if (is_dynamic_overwrite) {
+    // A replace can only be re-applied if the files it removed are all 
present.
+    FailMissingDeletePaths();
+    replaced_partitions_.emplace();
+  }
+
+  auto io = ctx_->table->io();
+  ICEBERG_BUILDER_ASSIGN_OR_RETURN(
+      auto changes, ReadSnapshotChanges(*cherrypick_snapshot_, io, current));
+
+  for (const auto& added_file : changes.added) {
+    ICEBERG_BUILDER_RETURN_IF_ERROR(AddDataFile(added_file));
+    if (replaced_partitions_.has_value()) {
+      ICEBERG_BUILDER_CHECK(added_file->partition_spec_id.has_value(),
+                            "Data file must have partition spec ID");
+      replaced_partitions_->add(added_file->partition_spec_id.value(),
+                                added_file->partition);
+    }
+  }
+
+  if (is_dynamic_overwrite) {
+    for (const auto& deleted_file : changes.removed) {
+      ICEBERG_BUILDER_RETURN_IF_ERROR(DeleteDataFile(deleted_file));
+    }
+  }
+
+  return *this;
+}
+
+Status CherryPickOperation::ValidateNonAncestor(const TableMetadata& meta,
+                                                int64_t snapshot_id) const {
+  ICEBERG_ASSIGN_OR_RAISE(bool is_ancestor,
+                          SnapshotUtil::IsAncestorOf(meta, snapshot_id));
+  if (is_ancestor) {
+    return ValidationFailed("Cannot cherrypick snapshot {}: already an 
ancestor",
+                            snapshot_id);
+  }
+
+  const std::string snapshot_id_str = std::to_string(snapshot_id);
+  ICEBERG_ASSIGN_OR_RAISE(auto ancestor_ids, CurrentAncestorIds(meta));
+  for (int64_t ancestor_id : ancestor_ids) {
+    ICEBERG_ASSIGN_OR_RAISE(auto ancestor, meta.SnapshotById(ancestor_id));
+    auto it = ancestor->summary.find(SnapshotSummaryFields::kSourceSnapshotId);
+    if (it != ancestor->summary.end() && it->second == snapshot_id_str) {
+      return ValidationFailed(
+          "Cannot cherrypick snapshot {}: already picked to create ancestor 
{}",
+          snapshot_id, ancestor_id);
+    }
+  }
+  return {};
+}
+
+Status CherryPickOperation::ValidateReplacedPartitions(const TableMetadata& 
meta) const {
+  if (!replaced_partitions_.has_value() ||
+      meta.current_snapshot_id == kInvalidSnapshotId) {
+    return {};
+  }
+
+  const auto parent_id = cherrypick_snapshot_->parent_snapshot_id;
+  if (parent_id.has_value()) {
+    ICEBERG_ASSIGN_OR_RAISE(bool is_ancestor,
+                            SnapshotUtil::IsAncestorOf(meta, 
parent_id.value()));
+    if (!is_ancestor) {
+      return ValidationFailed(
+          "Cannot cherry-pick overwrite, based on non-ancestor of the current 
state: {}",
+          parent_id.value());
+    }
+  }
+
+  // Walk back from the current snapshot to the picked snapshot's parent and
+  // reject any file added into a partition this pick replaces.
+  auto io = ctx_->table->io();
+  ICEBERG_ASSIGN_OR_RAISE(auto snapshots, SnapshotUtil::AncestorsBetween(
+                                              meta, meta.current_snapshot_id, 
parent_id));
+  // TODO: Parallelize added-file validation using the planning executor.
+  // Bound active readers and wait for in-flight tasks before returning.
+  for (const auto& snap : snapshots) {
+    SnapshotReader snapshot_reader(snap.get());
+    ICEBERG_ASSIGN_OR_RAISE(auto manifests, snapshot_reader.DataManifests(io));
+    for (const auto& manifest : manifests) {
+      if (manifest.added_snapshot_id != snap->snapshot_id) {
+        continue;
+      }
+      ICEBERG_ASSIGN_OR_RAISE(auto reader, MakeManifestReader(manifest, io, 
meta));
+      ICEBERG_ASSIGN_OR_RAISE(auto entries, reader->EntriesStream());
+      while (true) {
+        ICEBERG_ASSIGN_OR_RAISE(auto entry, entries->Next());
+        if (!entry.has_value()) {
+          break;
+        }
+        if (entry->status != ManifestStatus::kAdded || !entry->data_file) {
+          continue;
+        }
+        const auto& new_file = entry->data_file;
+        if (!new_file->partition_spec_id.has_value() ||
+            
!replaced_partitions_->contains(new_file->partition_spec_id.value(),
+                                            new_file->partition)) {
+          continue;
+        }
+        ICEBERG_ASSIGN_OR_RAISE(auto spec,
+                                
meta.PartitionSpecById(*new_file->partition_spec_id));
+        ICEBERG_ASSIGN_OR_RAISE(auto partition_path,
+                                spec->PartitionPath(new_file->partition));
+        return ValidationFailed(
+            "Cannot cherry-pick replace partitions with changed partition: {}",
+            partition_path);
+      }
+    }
+  }
+  return {};
+}
+
+Status CherryPickOperation::Validate(
+    const TableMetadata& base,
+    [[maybe_unused]] const std::shared_ptr<Snapshot>& snapshot) {
+  if (IsFastForward(base)) {
+    return {};
+  }
+
+  ICEBERG_RETURN_UNEXPECTED(ValidateNonAncestor(base, 
cherrypick_snapshot_->snapshot_id));
+  ICEBERG_RETURN_UNEXPECTED(ValidateReplacedPartitions(base));
+  ICEBERG_RETURN_UNEXPECTED(ValidateWapPublish(base, 
cherrypick_snapshot_->snapshot_id));
+  return {};
+}
+
+}  // namespace iceberg
diff --git a/src/iceberg/update/cherry_pick_operation.h 
b/src/iceberg/update/cherry_pick_operation.h
new file mode 100644
index 00000000..5c3211c4
--- /dev/null
+++ b/src/iceberg/update/cherry_pick_operation.h
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+/// \file iceberg/update/cherry_pick_operation.h
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+
+#include "iceberg/iceberg_export.h"
+#include "iceberg/result.h"
+#include "iceberg/type_fwd.h"
+#include "iceberg/update/merging_snapshot_update.h"
+#include "iceberg/util/partition_value_util.h"
+
+namespace iceberg {
+
+/// \brief Cherry-picks or fast-forwards the current state to a snapshot.
+///
+/// This update is not exposed through the Table API. It is part of the
+/// Transaction API intended for use in SnapshotManager.
+class ICEBERG_EXPORT CherryPickOperation : public MergingSnapshotUpdate {
+ public:
+  /// \brief Create a new CherryPickOperation instance.
+  ///
+  /// \param table_name The name of the table
+  /// \param ctx The transaction context
+  /// \return A new CherryPickOperation instance
+  static Result<std::unique_ptr<CherryPickOperation>> Make(
+      std::string table_name, std::shared_ptr<TransactionContext> ctx);
+
+  /// \brief Cherry-pick the changes of the given snapshot, or fast-forward to 
it.
+  ///
+  /// \param snapshot_id The ID of the snapshot whose changes to apply
+  /// \return Reference to this for method chaining
+  CherryPickOperation& Cherrypick(int64_t snapshot_id);
+
+ protected:
+  Result<ApplyResult> Apply() override;
+
+  std::string operation() override;
+
+  Status Validate(const TableMetadata& base,
+                  const std::shared_ptr<Snapshot>& snapshot) override;
+
+ private:
+  explicit CherryPickOperation(std::string table_name,
+                               std::shared_ptr<TransactionContext> ctx);
+
+  /// \brief Whether the snapshot passed to Cherrypick() can be fast-forwarded.
+  bool IsFastForward(const TableMetadata& base) const;
+
+  /// \brief Fail if the picked snapshot is already part of the current 
history,
+  /// either directly or as the source of an earlier pick.
+  Status ValidateNonAncestor(const TableMetadata& meta, int64_t snapshot_id) 
const;
+
+  /// \brief If there is a current snapshot, require an ancestral parent (if 
any)
+  /// and reject files added to replaced partitions since that parent.
+  Status ValidateReplacedPartitions(const TableMetadata& meta) const;
+
+  std::shared_ptr<Snapshot> cherrypick_snapshot_;
+  bool require_fast_forward_ = false;
+  std::optional<PartitionSet> replaced_partitions_;
+};
+
+}  // namespace iceberg
diff --git a/src/iceberg/update/snapshot_manager.cc 
b/src/iceberg/update/snapshot_manager.cc
index 405b029f..d825403b 100644
--- a/src/iceberg/update/snapshot_manager.cc
+++ b/src/iceberg/update/snapshot_manager.cc
@@ -23,6 +23,7 @@
 #include "iceberg/table.h"
 #include "iceberg/table_metadata.h"
 #include "iceberg/transaction.h"
+#include "iceberg/update/cherry_pick_operation.h"
 #include "iceberg/update/fast_append.h"
 #include "iceberg/update/set_snapshot.h"
 #include "iceberg/update/update_snapshot_reference.h"
@@ -55,8 +56,10 @@ SnapshotManager::~SnapshotManager() = default;
 
 SnapshotManager& SnapshotManager::Cherrypick(int64_t snapshot_id) {
   ICEBERG_BUILDER_RETURN_IF_ERROR(CommitIfRefUpdatesExist());
-  // TODO(anyone): Implement cherrypick operation
-  ICEBERG_BUILDER_CHECK(false, "Cherrypick operation not yet implemented");
+  ICEBERG_BUILDER_ASSIGN_OR_RETURN(auto cherry_pick,
+                                   transaction_->NewCherryPickOperation());
+  cherry_pick->Cherrypick(snapshot_id);
+  ICEBERG_BUILDER_RETURN_IF_ERROR(cherry_pick->Commit());
   return *this;
 }
 
diff --git a/src/iceberg/update/snapshot_manager.h 
b/src/iceberg/update/snapshot_manager.h
index fd81f833..3dfdb25f 100644
--- a/src/iceberg/update/snapshot_manager.h
+++ b/src/iceberg/update/snapshot_manager.h
@@ -46,7 +46,9 @@ namespace iceberg {
 /// instead of the one that was current when the audited changes were created. 
This class
 /// adds support for cherry-picking the changes from an orphan snapshot by 
applying them
 /// to the current snapshot. The output of the operation is a new snapshot 
with the
-/// changes from cherry-picked snapshot.
+/// changes from cherry-picked snapshot, except when the picked snapshot is a 
child of
+/// the current one, in which case the current state fast-forwards to it and 
no snapshot
+/// is created.
 class ICEBERG_EXPORT SnapshotManager : public ErrorCollector {
  public:
   /// \brief Create a SnapshotManager that owns its own transaction.
@@ -63,6 +65,9 @@ class ICEBERG_EXPORT SnapshotManager : public ErrorCollector {
   /// \brief Apply supported changes in given snapshot and create a new 
snapshot which
   /// will be set as the current snapshot on commit.
   ///
+  /// If the given snapshot is a child of the current snapshot, the current 
state
+  /// fast-forwards to it on commit and no new snapshot is created.
+  ///
   /// \param snapshot_id a Snapshot ID whose changes to apply
   /// \return Reference to this for method chaining
   SnapshotManager& Cherrypick(int64_t snapshot_id);
diff --git a/src/iceberg/update/snapshot_update.h 
b/src/iceberg/update/snapshot_update.h
index cfb05188..3f06c8b3 100644
--- a/src/iceberg/update/snapshot_update.h
+++ b/src/iceberg/update/snapshot_update.h
@@ -62,6 +62,7 @@ class ICEBERG_EXPORT SnapshotUpdate : public PendingUpdate {
  public:
   /// \brief Result of applying a snapshot update
   struct ApplyResult {
+    // Null when applying an unconfigured update to an empty table.
     std::shared_ptr<Snapshot> snapshot;
     std::string target_branch;
     bool stage_only = false;
@@ -200,7 +201,7 @@ class ICEBERG_EXPORT SnapshotUpdate : public PendingUpdate {
   friend class Transaction;
 
   /// Build snapshot state for the transaction's current metadata.
-  Result<ApplyResult> Apply();
+  virtual Result<ApplyResult> Apply();
   Status Finalize(const TableMetadata& committed) override;
   Status CleanStaged() override;
 
diff --git a/src/iceberg/util/snapshot_util.cc 
b/src/iceberg/util/snapshot_util.cc
index 6aed2826..885189c2 100644
--- a/src/iceberg/util/snapshot_util.cc
+++ b/src/iceberg/util/snapshot_util.cc
@@ -66,9 +66,11 @@ Result<bool> SnapshotUtil::IsAncestorOf(const Table& table,
 
 Result<bool> SnapshotUtil::IsAncestorOf(const TableMetadata& metadata,
                                         int64_t ancestor_snapshot_id) {
-  ICEBERG_ASSIGN_OR_RAISE(auto current, metadata.Snapshot());
-  ICEBERG_CHECK(current != nullptr, "Current snapshot is null");
-  return IsAncestorOf(metadata, current->snapshot_id, ancestor_snapshot_id);
+  // An empty current state has no ancestors.
+  if (metadata.current_snapshot_id == kInvalidSnapshotId) {
+    return false;
+  }
+  return IsAncestorOf(metadata, metadata.current_snapshot_id, 
ancestor_snapshot_id);
 }
 
 Result<bool> SnapshotUtil::IsAncestorOf(const Table& table, int64_t 
snapshot_id,

Reply via email to