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

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 634110752b2f test(cli): cover timeline, export, metadata commands and 
the SparkMain helpers (#19877)
634110752b2f is described below

commit 634110752b2f0bd1fbcba265bb3e4f3252368269
Author: voonhous <[email protected]>
AuthorDate: Thu Sep 10 16:56:41 2026 +0800

    test(cli): cover timeline, export, metadata commands and the SparkMain 
helpers (#19877)
    
    Second round of hudi-cli coverage after #18816 wired the module into
    CI. TimelineCommand, ExportCommand and most of MetadataCommand had no
    tests, and the SparkMain helpers were only reached through the
    spark-submit child process the shell commands launch, which JaCoCo
    never sees.
    
    - TestTimelineCommand (new, 8): timeline show active / incomplete and
      metadata timeline show active / incomplete with every option; the
      fixture holds completed, requested and rolled-back commits plus a
      pending rollback so both rollback annotations render.
    - TestExportCommand (new, 2): export instants, decoding the exported
      files through the table serde and asserting the written partitions,
      and the invalid-folder guard.
    - TestMetadataCommand (+4): stats, list-partitions, list-files with
      and without a partition, validate-files in both verbose arms with a
      planted stray base file, create and init, set.
    - TestCompactionCommand (+6): compaction validate on a healthy plan
      and on one with a deleted log file, repair, unschedule plan and
      unschedule file with and without dryRun, and schedule-and-execute
      compaction on a MOR table with log files.
    - TestClusteringCommand (new, 1), TestRollbacksCommand (+2) and
      TestSavepointsCommand (+1): the cluster, rollback and savepoint
      entry points, including the failure return codes.
    - CLIFunctionalTestHarness gains renderedRows, a parser for
      HoodiePrintHelper tables so tests assert on cell values, and
      createTableAndConnect, which skips the five-second existing-table
      probe in TableCommand.createTable.
    
    The only production change is visibility: ten SparkMain helpers go
    from private static to package-private @VisibleForTesting static,
    matching the existing archive helper, so they can be called
    in-process. No behaviour change.
---
 .../org/apache/hudi/cli/commands/SparkMain.java    |  46 +--
 .../hudi/cli/commands/TestClusteringCommand.java   | 106 +++++++
 .../hudi/cli/commands/TestCompactionCommand.java   | 232 +++++++++++++++
 .../hudi/cli/commands/TestExportCommand.java       | 169 +++++++++++
 .../hudi/cli/commands/TestMetadataCommand.java     | 229 +++++++++++++++
 .../hudi/cli/commands/TestRollbacksCommand.java    |  39 ++-
 .../hudi/cli/commands/TestSavepointsCommand.java   |  62 ++++
 .../hudi/cli/commands/TestTimelineCommand.java     | 316 +++++++++++++++++++++
 .../cli/functional/CLIFunctionalTestHarness.java   |  99 +++++++
 9 files changed, 1279 insertions(+), 19 deletions(-)

diff --git a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/SparkMain.java 
b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/SparkMain.java
index 121c1f8b4231..8a64538eadc2 100644
--- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/SparkMain.java
+++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/SparkMain.java
@@ -286,8 +286,9 @@ public class SparkMain {
     }
   }
 
-  private static void doCompactValidate(JavaSparkContext jsc, String basePath, 
String compactionInstant,
-                                        String outputPath, int parallelism) 
throws Exception {
+  @VisibleForTesting
+  static void doCompactValidate(JavaSparkContext jsc, String basePath, String 
compactionInstant,
+                                String outputPath, int parallelism) throws 
Exception {
     HoodieCompactionAdminTool.Config cfg = new 
HoodieCompactionAdminTool.Config();
     cfg.basePath = basePath;
     cfg.operation = Operation.VALIDATE;
@@ -297,8 +298,9 @@ public class SparkMain {
     new HoodieCompactionAdminTool(cfg).run(jsc);
   }
 
-  private static void doCompactRepair(JavaSparkContext jsc, String basePath, 
String compactionInstant,
-                                      String outputPath, int parallelism, 
boolean dryRun) throws Exception {
+  @VisibleForTesting
+  static void doCompactRepair(JavaSparkContext jsc, String basePath, String 
compactionInstant,
+                              String outputPath, int parallelism, boolean 
dryRun) throws Exception {
     HoodieCompactionAdminTool.Config cfg = new 
HoodieCompactionAdminTool.Config();
     cfg.basePath = basePath;
     cfg.operation = Operation.REPAIR;
@@ -309,8 +311,9 @@ public class SparkMain {
     new HoodieCompactionAdminTool(cfg).run(jsc);
   }
 
-  private static void doCompactUnschedule(JavaSparkContext jsc, String 
basePath, String compactionInstant,
-                                          String outputPath, int parallelism, 
boolean skipValidation, boolean dryRun) throws Exception {
+  @VisibleForTesting
+  static void doCompactUnschedule(JavaSparkContext jsc, String basePath, 
String compactionInstant,
+                                  String outputPath, int parallelism, boolean 
skipValidation, boolean dryRun) throws Exception {
     HoodieCompactionAdminTool.Config cfg = new 
HoodieCompactionAdminTool.Config();
     cfg.basePath = basePath;
     cfg.operation = Operation.UNSCHEDULE_PLAN;
@@ -322,8 +325,9 @@ public class SparkMain {
     new HoodieCompactionAdminTool(cfg).run(jsc);
   }
 
-  private static void doCompactUnscheduleFile(JavaSparkContext jsc, String 
basePath, String fileId, String partitionPath,
-                                              String outputPath, int 
parallelism, boolean skipValidation, boolean dryRun)
+  @VisibleForTesting
+  static void doCompactUnscheduleFile(JavaSparkContext jsc, String basePath, 
String fileId, String partitionPath,
+                                      String outputPath, int parallelism, 
boolean skipValidation, boolean dryRun)
       throws Exception {
     HoodieCompactionAdminTool.Config cfg = new 
HoodieCompactionAdminTool.Config();
     cfg.basePath = basePath;
@@ -337,9 +341,10 @@ public class SparkMain {
     new HoodieCompactionAdminTool(cfg).run(jsc);
   }
 
-  private static int compact(JavaSparkContext jsc, String basePath, String 
tableName, String compactionInstant,
-                             int parallelism, String schemaFile, int retry, 
String mode, String propsFilePath,
-                             List<String> configs) {
+  @VisibleForTesting
+  static int compact(JavaSparkContext jsc, String basePath, String tableName, 
String compactionInstant,
+                     int parallelism, String schemaFile, int retry, String 
mode, String propsFilePath,
+                     List<String> configs) {
     HoodieCompactor.Config cfg = new HoodieCompactor.Config();
     cfg.basePath = basePath;
     cfg.tableName = tableName;
@@ -354,8 +359,9 @@ public class SparkMain {
     return new HoodieCompactor(jsc, cfg).compact(retry);
   }
 
-  private static int cluster(JavaSparkContext jsc, String basePath, String 
tableName, String clusteringInstant,
-                             int parallelism, String sparkMemory, int retry, 
String runningMode, String propsFilePath, List<String> configs) {
+  @VisibleForTesting
+  static int cluster(JavaSparkContext jsc, String basePath, String tableName, 
String clusteringInstant,
+                     int parallelism, String sparkMemory, int retry, String 
runningMode, String propsFilePath, List<String> configs) {
     HoodieClusteringJob.Config cfg = new HoodieClusteringJob.Config();
     cfg.basePath = basePath;
     cfg.tableName = tableName;
@@ -504,7 +510,8 @@ public class SparkMain {
     return 0;
   }
 
-  private static int rollback(JavaSparkContext jsc, String instantTime, String 
basePath, Boolean rollbackUsingMarkers) throws Exception {
+  @VisibleForTesting
+  static int rollback(JavaSparkContext jsc, String instantTime, String 
basePath, Boolean rollbackUsingMarkers) throws Exception {
     SparkRDDWriteClient client = createHoodieClient(jsc, basePath, 
rollbackUsingMarkers, false);
     if (client.rollback(instantTime)) {
       log.info("The commit \"{}\" rolled back.", instantTime);
@@ -515,8 +522,9 @@ public class SparkMain {
     }
   }
 
-  private static int createSavepoint(JavaSparkContext jsc, String commitTime, 
String user,
-                                     String comments, String basePath) throws 
Exception {
+  @VisibleForTesting
+  static int createSavepoint(JavaSparkContext jsc, String commitTime, String 
user,
+                             String comments, String basePath) throws 
Exception {
     try (SparkRDDWriteClient client = createHoodieClient(jsc, basePath, 
false)) {
       client.savepoint(commitTime, user, comments);
       log.info("The commit \"{}\" has been savepointed.", commitTime);
@@ -527,7 +535,8 @@ public class SparkMain {
     }
   }
 
-  private static int rollbackToSavepoint(JavaSparkContext jsc, String 
savepointTime, String basePath, boolean lazyCleanPolicy) throws Exception {
+  @VisibleForTesting
+  static int rollbackToSavepoint(JavaSparkContext jsc, String savepointTime, 
String basePath, boolean lazyCleanPolicy) throws Exception {
     try (SparkRDDWriteClient client = createHoodieClient(jsc, basePath, 
lazyCleanPolicy)) {
       client.restoreToSavepoint(savepointTime);
       log.info("The commit \"{}\" rolled back.", savepointTime);
@@ -538,7 +547,8 @@ public class SparkMain {
     }
   }
 
-  private static int deleteSavepoint(JavaSparkContext jsc, String 
savepointTime, String basePath) throws Exception {
+  @VisibleForTesting
+  static int deleteSavepoint(JavaSparkContext jsc, String savepointTime, 
String basePath) throws Exception {
     try (SparkRDDWriteClient client = createHoodieClient(jsc, basePath, 
false)) {
       client.deleteSavepoint(savepointTime);
       log.info("Savepoint \"{}\" deleted.", savepointTime);
diff --git 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestClusteringCommand.java
 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestClusteringCommand.java
new file mode 100644
index 000000000000..5f8014fedf8d
--- /dev/null
+++ 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestClusteringCommand.java
@@ -0,0 +1,106 @@
+/*
+ * 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.
+ */
+
+package org.apache.hudi.cli.commands;
+
+import org.apache.hudi.cli.HoodieCLI;
+import org.apache.hudi.cli.functional.CLIFunctionalTestHarness;
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.testutils.Assertions;
+import org.apache.hudi.utilities.UtilHelpers;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Test cases for the clustering entry point of {@link SparkMain}, which the 
clustering commands
+ * reach through a spark-submit of their own.
+ */
+@Tag("functional")
+@SpringBootTest(properties = {"spring.shell.interactive.enabled=false", 
"spring.shell.command.script.enabled=false"})
+public class TestClusteringCommand extends CLIFunctionalTestHarness {
+
+  private String tableName;
+  private String tablePath;
+
+  @BeforeEach
+  public void init() throws IOException {
+    HoodieCLI.conf = storageConf();
+    tableName = tableName();
+    tablePath = tablePath(tableName);
+
+    createTableAndConnect(tablePath, tableName, HoodieTableType.COPY_ON_WRITE, 
HoodieAvroPayload.class.getName());
+  }
+
+  @Test
+  public void testSparkMainClusterScheduleAndExecute() throws Exception {
+    writeCommits();
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(HoodieCLI.getTableMetaClient());
+    assertEquals(0, 
metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants());
+
+    int returnCode = SparkMain.cluster(jsc(), tablePath, tableName, null, 1, 
"1g", 0,
+        UtilHelpers.SCHEDULE_AND_EXECUTE, null, Collections.emptyList());
+
+    assertEquals(0, returnCode);
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    // the plan it scheduled ran to completion, leaving a replace commit and 
nothing pending
+    assertEquals(0, 
metaClient.getActiveTimeline().filterPendingClusteringTimeline().countInstants());
+    assertEquals(1, 
metaClient.getActiveTimeline().getCompletedReplaceTimeline().countInstants());
+  }
+
+  /**
+   * Writes two commits of small files into the table, which is what the 
clustering plan strategy
+   * looks for.
+   */
+  private void writeCommits() {
+    HoodieTestDataGenerator dataGen = new HoodieTestDataGenerator(new String[] 
{DEFAULT_FIRST_PARTITION_PATH});
+    HoodieWriteConfig config = 
HoodieWriteConfig.newBuilder().withPath(tablePath)
+        .withSchema(TRIP_EXAMPLE_SCHEMA).withParallelism(1, 
1).forTable(tableName).build();
+    try (SparkRDDWriteClient client = new SparkRDDWriteClient(context(), 
config)) {
+      String firstCommit = client.startCommit();
+      writeAndCommit(client, dataGen.generateInserts(firstCommit, 10), 
firstCommit);
+      String secondCommit = client.startCommit();
+      writeAndCommit(client, dataGen.generateInserts(secondCommit, 10), 
secondCommit);
+    }
+  }
+
+  private void writeAndCommit(SparkRDDWriteClient client, List<HoodieRecord> 
records, String commitTime) {
+    JavaRDD<HoodieRecord> writeRecords = jsc().parallelize(records, 1);
+    List<WriteStatus> result = client.insert(writeRecords, 
commitTime).collect();
+    client.commit(commitTime, jsc().parallelize(result));
+    Assertions.assertNoWriteErrors(result);
+  }
+}
diff --git 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestCompactionCommand.java
 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestCompactionCommand.java
index d25abe1789ad..3e849367ebe0 100644
--- 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestCompactionCommand.java
+++ 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestCompactionCommand.java
@@ -18,16 +18,22 @@
 
 package org.apache.hudi.cli.commands;
 
+import org.apache.hudi.avro.model.HoodieCompactionOperation;
 import org.apache.hudi.avro.model.HoodieCompactionPlan;
 import org.apache.hudi.cli.HoodieCLI;
 import org.apache.hudi.cli.HoodiePrintHelper;
 import org.apache.hudi.cli.TableHeader;
 import org.apache.hudi.cli.functional.CLIFunctionalTestHarness;
 import org.apache.hudi.cli.testutils.HoodieTestCommitMetadataGenerator;
+import org.apache.hudi.client.CompactionAdminClient.ValidationOpResult;
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteStatus;
 import org.apache.hudi.client.timeline.HoodieTimelineArchiver;
 import org.apache.hudi.client.timeline.TimelineArchiverV2;
 import org.apache.hudi.common.model.HoodieAvroPayload;
 import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieFileGroupId;
+import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.HoodieTableVersion;
@@ -35,35 +41,53 @@ import 
org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
 import org.apache.hudi.common.table.view.FileSystemViewStorageConfig;
 import org.apache.hudi.common.testutils.CompactionTestUtils;
+import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
 import org.apache.hudi.common.testutils.HoodieTestUtils;
+import org.apache.hudi.common.util.CompactionUtils;
+import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.config.HoodieArchivalConfig;
 import org.apache.hudi.config.HoodieCleanConfig;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.exception.HoodieException;
 import org.apache.hudi.table.HoodieSparkTable;
+import org.apache.hudi.testutils.Assertions;
+import org.apache.hudi.utilities.UtilHelpers;
 
+import org.apache.spark.api.java.JavaRDD;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.shell.Shell;
 
 import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
+import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.COMMIT_ACTION;
 import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.COMPACTION_ACTION;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA;
 import static 
org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * Test Cases for {@link CompactionCommand}.
@@ -75,6 +99,8 @@ public class TestCompactionCommand extends 
CLIFunctionalTestHarness {
   @Autowired
   private Shell shell;
 
+  private static final String PENDING_COMPACTION_INSTANT = "001";
+
   private String tableName;
   private String tablePath;
 
@@ -146,6 +172,212 @@ public class TestCompactionCommand extends 
CLIFunctionalTestHarness {
     assertNotNull(result);
   }
 
+  /**
+   * Test case of the compaction validation entry point of {@link SparkMain}, 
which the
+   * 'compaction validate' command reaches through a spark-submit of its own.
+   */
+  @Test
+  public void testSparkMainCompactValidate() throws Exception {
+    createPendingCompactions();
+    String outputPath = outputPath("validate");
+
+    SparkMain.doCompactValidate(jsc(), tablePath, PENDING_COMPACTION_INSTANT, 
outputPath, 2);
+
+    List<ValidationOpResult> results = readOperationResults(outputPath);
+    assertEquals(operationsOf(PENDING_COMPACTION_INSTANT).size(), 
results.size());
+    assertTrue(results.stream().allMatch(ValidationOpResult::isSuccess), 
results.toString());
+    assertEquals(fileIdsOf(PENDING_COMPACTION_INSTANT),
+        results.stream().map(result -> 
result.getOperation().getFileId()).collect(Collectors.toSet()));
+  }
+
+  @Test
+  public void testSparkMainCompactValidateReportsMissingLogFile() throws 
Exception {
+    createPendingCompactions();
+    HoodieCompactionOperation broken = 
operationsOf(PENDING_COMPACTION_INSTANT).get(0);
+    // a log file the plan reads is gone, so that operation can no longer be 
compacted
+    Files.delete(Paths.get(tablePath, broken.getPartitionPath(), 
broken.getDeltaFilePaths().get(0)));
+    String outputPath = outputPath("validate-broken");
+
+    SparkMain.doCompactValidate(jsc(), tablePath, PENDING_COMPACTION_INSTANT, 
outputPath, 2);
+
+    List<ValidationOpResult> results = readOperationResults(outputPath);
+    assertEquals(operationsOf(PENDING_COMPACTION_INSTANT).size(), 
results.size());
+    List<ValidationOpResult> failed = results.stream().filter(result -> 
!result.isSuccess()).collect(Collectors.toList());
+    assertEquals(1, failed.size(), results.toString());
+    assertEquals(broken.getFileId(), failed.get(0).getOperation().getFileId());
+    assertTrue(failed.get(0).getException().isPresent());
+  }
+
+  /**
+   * Repair runs the plan validation and returns an empty result: the log file 
renaming it was
+   * written for is gone from the admin client, which leaves the plan 
untouched and never reads
+   * the dry run flag, so there is only one arm to exercise. See
+   * https://github.com/apache/hudi/issues/19881.
+   */
+  @Test
+  public void testSparkMainCompactRepair() throws Exception {
+    createPendingCompactions();
+    Set<String> fileIdsBefore = fileIdsOf(PENDING_COMPACTION_INSTANT);
+    String outputPath = outputPath("repair");
+
+    SparkMain.doCompactRepair(jsc(), tablePath, PENDING_COMPACTION_INSTANT, 
outputPath, 2, false);
+
+    assertTrue(readOperationResults(outputPath).isEmpty());
+    
assertTrue(pendingCompactionInstants().contains(PENDING_COMPACTION_INSTANT));
+    assertEquals(fileIdsBefore, fileIdsOf(PENDING_COMPACTION_INSTANT));
+  }
+
+  /**
+   * Unscheduling a plan takes the requested compaction instant off the 
timeline, unless this is a
+   * dry run. The other pending plans are left alone either way. Skip 
validation is held at false:
+   * the admin client takes the flag but never reads it, so toggling it 
repeats the same run.
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testSparkMainCompactUnschedulePlan(boolean dryRun) throws 
Exception {
+    createPendingCompactions();
+    Set<String> pendingBefore = pendingCompactionInstants();
+    String outputPath = outputPath("unschedule-" + dryRun);
+
+    SparkMain.doCompactUnschedule(jsc(), tablePath, 
PENDING_COMPACTION_INSTANT, outputPath, 2, false, dryRun);
+
+    assertTrue(readOperationResults(outputPath).isEmpty());
+    Set<String> pendingAfter = pendingCompactionInstants();
+    if (dryRun) {
+      assertEquals(pendingBefore, pendingAfter);
+    } else {
+      assertFalse(pendingAfter.contains(PENDING_COMPACTION_INSTANT), 
pendingAfter.toString());
+      pendingBefore.remove(PENDING_COMPACTION_INSTANT);
+      assertEquals(pendingBefore, pendingAfter);
+    }
+  }
+
+  /**
+   * Unscheduling a single file group rewrites the plan without it, unless 
this is a dry run. Skip
+   * validation is held at false: the admin client takes the flag but never 
reads it, so toggling
+   * it repeats the same run.
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testSparkMainCompactUnscheduleFile(boolean dryRun) throws 
Exception {
+    Map<HoodieFileGroupId, Pair<String, HoodieCompactionOperation>> 
pendingOperations = createPendingCompactions();
+    HoodieFileGroupId unscheduled = pendingOperations.entrySet().stream()
+        .filter(entry -> 
entry.getValue().getKey().equals(PENDING_COMPACTION_INSTANT))
+        .map(Map.Entry::getKey).findFirst().get();
+    String outputPath = outputPath("unschedule-file-" + dryRun);
+    // both operations of this plan sit in the same partition, so there is a 
sibling to keep
+    Set<String> fileIdsBefore = fileIdsOf(PENDING_COMPACTION_INSTANT);
+    assertEquals(2, fileIdsBefore.size());
+    assertTrue(fileIdsBefore.contains(unscheduled.getFileId()));
+
+    SparkMain.doCompactUnscheduleFile(jsc(), tablePath, 
unscheduled.getFileId(), unscheduled.getPartitionPath(),
+        outputPath, 2, false, dryRun);
+
+    assertTrue(readOperationResults(outputPath).isEmpty());
+    // the plan itself stays pending either way, only its operations change
+    
assertTrue(pendingCompactionInstants().contains(PENDING_COMPACTION_INSTANT));
+    if (dryRun) {
+      assertEquals(fileIdsBefore, fileIdsOf(PENDING_COMPACTION_INSTANT));
+    } else {
+      // The admin client keeps the operations that differ from the 
unscheduled one in file id AND
+      // in partition path, so the sibling operation goes with it and the plan 
is left with no
+      // operations at all (https://github.com/apache/hudi/issues/19881). When 
that is fixed this
+      // expectation has to become the sibling on its own:
+      // fileIdsBefore minus the unscheduled file id.
+      assertEquals(Collections.emptySet(), 
fileIdsOf(PENDING_COMPACTION_INSTANT));
+    }
+  }
+
+  /**
+   * A MOR table with four pending compaction plans, of which {@link 
#PENDING_COMPACTION_INSTANT}
+   * holds more than one operation.
+   *
+   * @return The pending compaction operations, by file group.
+   */
+  private Map<HoodieFileGroupId, Pair<String, HoodieCompactionOperation>> 
createPendingCompactions() throws IOException {
+    createTableAndConnect(tablePath, tableName, HoodieTableType.MERGE_ON_READ, 
HoodieAvroPayload.class.getName());
+    Map<HoodieFileGroupId, Pair<String, HoodieCompactionOperation>> operations 
=
+        
CompactionTestUtils.setupAndValidateCompactionOperations(HoodieCLI.getTableMetaClient(),
 false, 2, 1, 1, 1);
+    HoodieCLI.getTableMetaClient().reloadActiveTimeline();
+    return operations;
+  }
+
+  private String outputPath(String name) {
+    return Paths.get(basePath(), "compaction-admin-" + name).toString();
+  }
+
+  private Set<String> pendingCompactionInstants() {
+    return 
HoodieCLI.getTableMetaClient().reloadActiveTimeline().filterPendingCompactionTimeline()
+        
.getInstantsAsStream().map(HoodieInstant::requestedTime).collect(Collectors.toSet());
+  }
+
+  private List<HoodieCompactionOperation> operationsOf(String 
compactionInstant) throws IOException {
+    HoodieTableMetaClient metaClient = HoodieCLI.getTableMetaClient();
+    metaClient.reloadActiveTimeline();
+    return CompactionUtils.getCompactionPlan(metaClient, 
compactionInstant).getOperations();
+  }
+
+  private Set<String> fileIdsOf(String compactionInstant) throws IOException {
+    return operationsOf(compactionInstant).stream()
+        .map(HoodieCompactionOperation::getFileId).collect(Collectors.toSet());
+  }
+
+  @SuppressWarnings("unchecked")
+  private <T> List<T> readOperationResults(String outputPath) throws Exception 
{
+    try (ObjectInputStream in = new 
ObjectInputStream(Files.newInputStream(Paths.get(outputPath)))) {
+      return (List<T>) in.readObject();
+    }
+  }
+
+  /**
+   * Test case of the compaction entry point of {@link SparkMain}, which the 
'compaction run' and
+   * 'compaction scheduleAndExecute' commands reach through a spark-submit of 
their own.
+   */
+  @Test
+  public void testSparkMainCompact() throws Exception {
+    writeDeltaCommits();
+    assertEquals(0, 
HoodieTableMetaClient.reload(HoodieCLI.getTableMetaClient())
+        
.getActiveTimeline().filterPendingCompactionTimeline().countInstants());
+
+    int returnCode = SparkMain.compact(jsc(), tablePath, tableName, null, 1, 
"", 0,
+        UtilHelpers.SCHEDULE_AND_EXECUTE, null,
+        
Collections.singletonList("hoodie.compact.inline.max.delta.commits=1"));
+
+    assertEquals(0, returnCode);
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.reload(HoodieCLI.getTableMetaClient());
+    // the plan it scheduled ran to completion, leaving a commit and nothing 
pending
+    assertEquals(0, 
metaClient.getActiveTimeline().filterPendingCompactionTimeline().countInstants());
+    assertEquals(1, metaClient.getActiveTimeline().filterCompletedInstants()
+        .filter(instant -> 
COMMIT_ACTION.equals(instant.getAction())).countInstants());
+  }
+
+  /**
+   * Writes two delta commits into a new MOR table at {@link #tablePath}, the 
second one updating
+   * the records of the first so that the file groups have log files to 
compact.
+   */
+  private void writeDeltaCommits() throws IOException {
+    createTableAndConnect(tablePath, tableName, HoodieTableType.MERGE_ON_READ, 
HoodieAvroPayload.class.getName());
+
+    HoodieTestDataGenerator dataGen = new HoodieTestDataGenerator(new String[] 
{DEFAULT_FIRST_PARTITION_PATH});
+    HoodieWriteConfig config = 
HoodieWriteConfig.newBuilder().withPath(tablePath)
+        .withSchema(TRIP_EXAMPLE_SCHEMA).withParallelism(1, 
1).forTable(tableName).build();
+    try (SparkRDDWriteClient client = new SparkRDDWriteClient(context(), 
config)) {
+      String firstCommit = client.startCommit();
+      List<HoodieRecord> records = dataGen.generateInserts(firstCommit, 10);
+      writeAndCommit(client, records, firstCommit);
+
+      String secondCommit = client.startCommit();
+      writeAndCommit(client, dataGen.generateUpdates(secondCommit, 5), 
secondCommit);
+    }
+  }
+
+  private void writeAndCommit(SparkRDDWriteClient client, List<HoodieRecord> 
records, String commitTime) {
+    JavaRDD<HoodieRecord> writeRecords = jsc().parallelize(records, 1);
+    List<WriteStatus> result = client.upsert(writeRecords, 
commitTime).collect();
+    client.commit(commitTime, jsc().parallelize(result));
+    Assertions.assertNoWriteErrors(result);
+  }
+
   private void generateCompactionInstances() throws IOException {
     // create MOR table.
     new TableCommand().createTable(
diff --git 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestExportCommand.java 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestExportCommand.java
new file mode 100644
index 000000000000..c0128d70a4a4
--- /dev/null
+++ b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestExportCommand.java
@@ -0,0 +1,169 @@
+/*
+ * 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.
+ */
+
+package org.apache.hudi.cli.commands;
+
+import org.apache.hudi.cli.HoodieCLI;
+import org.apache.hudi.cli.functional.CLIFunctionalTestHarness;
+import org.apache.hudi.cli.testutils.HoodieTestCommitMetadataGenerator;
+import org.apache.hudi.cli.testutils.ShellEvaluationResultUtil;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.HoodieWriteStat;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.exception.HoodieException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.shell.Shell;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link ExportCommand}.
+ */
+@Tag("functional")
+@SpringBootTest(properties = {"spring.shell.interactive.enabled=false", 
"spring.shell.command.script.enabled=false"})
+public class TestExportCommand extends CLIFunctionalTestHarness {
+
+  private static final String[] COMMIT_TIMES = new String[] {"101", "102", 
"103"};
+
+  @Autowired
+  private Shell shell;
+
+  private String tablePath;
+  private Path exportFolder;
+
+  @BeforeEach
+  public void init() throws Exception {
+    HoodieCLI.conf = storageConf();
+    String tableName = tableName();
+    tablePath = tablePath(tableName);
+    exportFolder = Files.createDirectories(Paths.get(basePath(), 
"exported-instants"));
+
+    createTableAndConnect(tablePath, tableName, HoodieTableType.COPY_ON_WRITE, 
HoodieAvroPayload.class.getName());
+    for (String commitTime : COMMIT_TIMES) {
+      
HoodieTestCommitMetadataGenerator.createCommitFileWithMetadata(tablePath, 
commitTime, storageConf());
+    }
+    HoodieCLI.refreshTableMetadata();
+  }
+
+  /**
+   * Exports the whole timeline. The instant count is passed as the limit and 
the ordering is
+   * descending on purpose: that is the one shape in which the export does not 
walk the archived
+   * timeline, whose reader cannot open the LSM timeline history directory of 
a table of version
+   * eight or above. The limit is not honoured for active instants either. 
Both are tracked in
+   * https://github.com/apache/hudi/issues/19879; once fixed, this test can 
drop the workaround.
+   */
+  @Test
+  public void testExportInstants() throws Exception {
+    Object result = shell.evaluate(
+        () -> "export instants --desc true --limit " + COMMIT_TIMES.length + " 
--localFolder " + exportFolder);
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result), 
String.valueOf(result));
+    assertEquals("Exported " + COMMIT_TIMES.length + " Instants to " + 
exportFolder, result.toString());
+
+    // one file per completed instant, named after the instant file it was 
read from
+    assertEquals(instantFileNames(COMMIT_TIMES), exportedFiles());
+
+    // The export copies the instant file off the timeline as it stands, in 
whatever format the
+    // table writes its commit metadata in, so it is read back through the 
table's own serde and
+    // compared with what the fixture wrote.
+    HoodieTableMetaClient metaClient = HoodieCLI.getTableMetaClient();
+    Set<String> writtenPartitions = new HashSet<>(
+        Arrays.asList(DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH));
+    for (HoodieInstant instant : 
metaClient.getActiveTimeline().filterCompletedInstants().getInstants()) {
+      Map<String, List<HoodieWriteStat>> writeStats = 
readExportedCommit(metaClient, instant).getPartitionToWriteStats();
+      assertEquals(writtenPartitions, writeStats.keySet());
+      for (List<HoodieWriteStat> partitionStats : writeStats.values()) {
+        assertEquals(1, partitionStats.size());
+        assertEquals(HoodieTestCommitMetadataGenerator.DEFAULT_NUM_WRITES, 
partitionStats.get(0).getNumWrites());
+        assertEquals(HoodieTestCommitMetadataGenerator.DEFAULT_PRE_COMMIT, 
partitionStats.get(0).getPrevCommit());
+      }
+    }
+  }
+
+  /**
+   * Reads back the file the export wrote for one instant.
+   *
+   * @param metaClient Meta client of the exported table.
+   * @param instant    Instant whose exported file to read.
+   * @return The commit metadata the file holds.
+   */
+  private HoodieCommitMetadata readExportedCommit(HoodieTableMetaClient 
metaClient, HoodieInstant instant)
+      throws IOException {
+    String fileName = 
metaClient.getInstantFileNameGenerator().getFileName(instant);
+    try (InputStream exported = 
Files.newInputStream(exportFolder.resolve(fileName))) {
+      return metaClient.getCommitMetadataSerDe().deserialize(instant, 
exported, () -> false, HoodieCommitMetadata.class);
+    }
+  }
+
+  @Test
+  public void testExportInstantsToInvalidFolder() {
+    String missingFolder = Paths.get(basePath(), "no-such-folder").toString();
+    Object result = shell.evaluate(() -> "export instants --localFolder " + 
missingFolder);
+    assertFalse(ShellEvaluationResultUtil.isSuccess(result));
+    assertEquals(HoodieException.class, result.getClass());
+    assertTrue(result.toString().contains(missingFolder + " is not a valid 
local directory"), result.toString());
+    assertFalse(Files.exists(Paths.get(missingFolder)));
+  }
+
+  private Set<String> exportedFiles() {
+    try (Stream<Path> files = Files.list(exportFolder)) {
+      return files.map(file -> 
file.getFileName().toString()).collect(Collectors.toSet());
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * The names of the instant files of the given commits, which are the names 
the export uses.
+   *
+   * @param commitTimes Requested times of the commits.
+   * @return The instant file names.
+   */
+  private Set<String> instantFileNames(String... commitTimes) {
+    HoodieTableMetaClient metaClient = HoodieCLI.getTableMetaClient();
+    List<String> wanted = Arrays.asList(commitTimes);
+    return 
metaClient.getActiveTimeline().filterCompletedInstants().getInstantsAsStream()
+        .filter(instant -> wanted.contains(instant.requestedTime()))
+        .map(instant -> 
metaClient.getInstantFileNameGenerator().getFileName(instant))
+        .collect(Collectors.toSet());
+  }
+}
diff --git 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestMetadataCommand.java 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestMetadataCommand.java
index 90bd4bb7b59b..0de8620cca0c 100644
--- 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestMetadataCommand.java
+++ 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestMetadataCommand.java
@@ -34,6 +34,8 @@ import 
org.apache.hudi.common.testutils.HoodieTestDataGenerator;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.keygen.SimpleKeyGenerator;
+import org.apache.hudi.metadata.HoodieTableMetadata;
+import org.apache.hudi.storage.StoragePath;
 import org.apache.hudi.testutils.Assertions;
 
 import org.apache.spark.api.java.JavaRDD;
@@ -45,12 +47,21 @@ import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.shell.Shell;
 
 import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA;
 import static org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -224,6 +235,224 @@ public class TestMetadataCommand extends 
CLIFunctionalTestHarness {
     }
   }
 
+  @Test
+  public void testMetadataStatsAndFileListing() throws Exception {
+    writeOneCommit(true);
+    connectToTable();
+
+    // The command opens the reader with metadata metrics off, so there is 
nothing to report on,
+    // but the stat table is still rendered. Tracked in 
https://github.com/apache/hudi/issues/19880;
+    // once fixed, assert on the rows instead.
+    Object stats = shell.evaluate(() -> "metadata stats");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(stats));
+    assertTrue(stats.toString().contains("stat key"), stats.toString());
+    assertTrue(renderedRows(stats.toString()).isEmpty(), stats.toString());
+
+    Object partitions = shell.evaluate(() -> "metadata list-partitions");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(partitions));
+    Set<String> written = writtenPartitions();
+    assertFalse(written.isEmpty());
+    assertEquals(written, renderedRows(partitions.toString()).stream()
+        .map(row -> row.get(0)).collect(Collectors.toSet()));
+
+    // The files of one partition, as the metadata table has them.
+    Object files = shell.evaluate(() -> "metadata list-files --partition " + 
DEFAULT_FIRST_PARTITION_PATH);
+    assertTrue(ShellEvaluationResultUtil.isSuccess(files));
+    Set<String> baseFiles = baseFilesOf(DEFAULT_FIRST_PARTITION_PATH);
+    assertFalse(baseFiles.isEmpty());
+    assertEquals(baseFiles.size(), renderedRows(files.toString()).size(), 
files.toString());
+    for (String baseFile : baseFiles) {
+      assertTrue(files.toString().contains(baseFile), files.toString());
+    }
+
+    // Without --partition the lookup key is the non-partitioned name ("."), 
which the files index
+    // of a partitioned table has no record for, so the lookup misses and 
nothing is listed.
+    Object rootFiles = shell.evaluate(() -> "metadata list-files");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(rootFiles));
+    assertTrue(renderedRows(rootFiles.toString()).isEmpty(), 
rootFiles.toString());
+  }
+
+  @Test
+  public void testMetadataValidateFiles() throws Exception {
+    writeOneCommit(true);
+    connectToTable();
+
+    // Every file on disk is in the metadata table, so nothing is reported 
without --verbose.
+    Object matching = shell.evaluate(() -> "metadata validate-files");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(matching));
+    assertTrue(renderedRows(matching.toString()).isEmpty(), 
matching.toString());
+
+    // --verbose reports every file, present on both sides and of the same 
size.
+    Object verbose = shell.evaluate(() -> "metadata validate-files --verbose 
true");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(verbose));
+    List<List<String>> rows = renderedRows(verbose.toString());
+    int filesOnDisk = writtenPartitions().stream().mapToInt(partition -> 
baseFilesOf(partition).size()).sum();
+    assertEquals(filesOnDisk, rows.size(), verbose.toString());
+    for (List<String> row : rows) {
+      assertEquals("true", row.get(2), row.toString());
+      assertEquals("true", row.get(3), row.toString());
+      assertEquals(row.get(4), row.get(5), row.toString());
+    }
+
+    // A base file the metadata table does not know about is reported even 
without --verbose.
+    String strayFile = writeStrayBaseFile(DEFAULT_FIRST_PARTITION_PATH);
+    Object mismatching = shell.evaluate(() -> "metadata validate-files");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(mismatching));
+    List<List<String>> mismatchingRows = renderedRows(mismatching.toString());
+    assertEquals(1, mismatchingRows.size(), mismatching.toString());
+    assertEquals(DEFAULT_FIRST_PARTITION_PATH, mismatchingRows.get(0).get(0));
+    assertEquals(strayFile, mismatchingRows.get(0).get(1));
+    assertEquals("true", mismatchingRows.get(0).get(2));
+    assertEquals("false", mismatchingRows.get(0).get(3));
+  }
+
+  @Test
+  public void testMetadataCreateAndInit() throws Exception {
+    // Written with the metadata table off, so that it does not exist yet.
+    writeOneCommit(false);
+    connectToTable();
+    StoragePath metadataPath = new 
StoragePath(HoodieTableMetadata.getMetadataTableBasePath(tablePath));
+    assertFalse(HoodieCLI.storage.exists(metadataPath));
+
+    // There is nothing to update yet.
+    Object tooEarly = shell.evaluate(() -> "metadata init");
+    assertFalse(ShellEvaluationResultUtil.isSuccess(tooEarly));
+    assertTrue(tooEarly.toString().contains("does not exist"), 
tooEarly.toString());
+
+    Object created = shell.evaluate(() -> "metadata create");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(created));
+    assertTrue(created.toString().startsWith("Created Metadata Table in " + 
metadataPath), created.toString());
+    HoodieTableMetaClient metadataMetaClient = HoodieTableMetaClient.builder()
+        
.setConf(HoodieCLI.conf.newInstance()).setBasePath(metadataPath.toString()).build();
+    int instantsAfterCreate = 
metadataMetaClient.getActiveTimeline().countInstants();
+    assertTrue(instantsAfterCreate > 0);
+
+    // The second create finds the directory of the first one.
+    Object again = shell.evaluate(() -> "metadata create");
+    assertFalse(ShellEvaluationResultUtil.isSuccess(again));
+    assertTrue(again.toString().contains("not empty"), again.toString());
+
+    // Read only init opens the table without writing to it.
+    Object opened = shell.evaluate(() -> "metadata init --readonly true");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(opened));
+    assertTrue(opened.toString().startsWith("Opened Metadata Table in " + 
metadataPath), opened.toString());
+    assertEquals(instantsAfterCreate,
+        
HoodieTableMetaClient.reload(metadataMetaClient).getActiveTimeline().countInstants());
+
+    Object initialized = shell.evaluate(() -> "metadata init");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(initialized));
+    assertTrue(initialized.toString().startsWith("Initialized Metadata Table 
in " + metadataPath),
+        initialized.toString());
+  }
+
+  @Test
+  public void testMetadataSetDirectory() throws Exception {
+    String customDirectory = tablePath + "-metadata";
+    try {
+      // An empty directory leaves the default location of the metadata table 
in place.
+      Object unset = shell.evaluate(() -> "metadata set");
+      assertTrue(ShellEvaluationResultUtil.isSuccess(unset));
+      assertEquals("Ok", unset.toString());
+      assertEquals(HoodieTableMetadata.getMetadataTableBasePath(tablePath),
+          MetadataCommand.getMetadataTableBasePath(tablePath));
+
+      Object set = shell.evaluate(() -> "metadata set --metadataDir " + 
customDirectory);
+      assertTrue(ShellEvaluationResultUtil.isSuccess(set));
+      assertEquals("Ok", set.toString());
+      assertEquals(customDirectory, 
MetadataCommand.getMetadataTableBasePath(tablePath));
+
+      // The directory is global to the session and can only be set once.
+      Object reset = shell.evaluate(() -> "metadata set --metadataDir " + 
customDirectory + "-other");
+      assertFalse(ShellEvaluationResultUtil.isSuccess(reset));
+      assertEquals(customDirectory, 
MetadataCommand.getMetadataTableBasePath(tablePath));
+    } finally {
+      clearMetadataBaseDirectory();
+    }
+  }
+
+  /**
+   * Writes one commit of ten records over two partitions into a new table at 
{@link #tablePath}.
+   *
+   * @param enableMetadataTable Whether the write maintains the metadata table.
+   */
+  private void writeOneCommit(boolean enableMetadataTable) throws Exception {
+    
writeOneCommit(HoodieMetadataConfig.newBuilder().enable(enableMetadataTable).build());
+  }
+
+  /**
+   * Writes one commit of ten records over two partitions into a new table at 
{@link #tablePath}.
+   *
+   * @param metadataConfig Metadata table configuration of the write.
+   */
+  private void writeOneCommit(HoodieMetadataConfig metadataConfig) throws 
Exception {
+    HoodieTableMetaClient.newTableBuilder()
+        .setTableType(HoodieTableType.COPY_ON_WRITE.name())
+        .setTableName(tableName)
+        
.setArchiveLogFolder(HoodieTableConfig.TIMELINE_HISTORY_PATH.defaultValue())
+        .setPayloadClassName("org.apache.hudi.common.model.HoodieAvroPayload")
+        .setPartitionFields("partition_path")
+        .setRecordKeyFields("_row_key")
+        .setKeyGeneratorClassProp(SimpleKeyGenerator.class.getCanonicalName())
+        .initTable(HoodieCLI.conf.newInstance(), tablePath);
+
+    HoodieTestDataGenerator dataGen = new HoodieTestDataGenerator(
+        new String[] {DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH});
+    HoodieWriteConfig config = HoodieWriteConfig.newBuilder()
+        .withPath(tablePath)
+        .withSchema(TRIP_EXAMPLE_SCHEMA)
+        .withMetadataConfig(metadataConfig)
+        .build();
+
+    try (SparkRDDWriteClient client = new SparkRDDWriteClient(context(), 
config)) {
+      String commitTime = client.startCommit();
+      List<HoodieRecord> records = dataGen.generateInserts(commitTime, 10);
+      JavaRDD<HoodieRecord> writeRecords = 
context().getJavaSparkContext().parallelize(records, 1);
+      List<WriteStatus> result = client.upsert(writeRecords, 
commitTime).collect();
+      client.commit(commitTime, jsc().parallelize(result));
+      Assertions.assertNoWriteErrors(result);
+    }
+  }
+
+  private void connectToTable() throws IOException {
+    new TableCommand().connect(tablePath, false, 0, 0, 0, 
"WAIT_TO_ADJUST_SKEW", 200L, false);
+  }
+
+  private Set<String> writtenPartitions() {
+    return Stream.of(DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH)
+        .filter(partition -> Files.isDirectory(Paths.get(tablePath, 
partition)))
+        .collect(Collectors.toSet());
+  }
+
+  private Set<String> baseFilesOf(String partition) {
+    try (Stream<Path> files = Files.list(Paths.get(tablePath, partition))) {
+      return files.map(file -> file.getFileName().toString())
+          .filter(name -> name.endsWith(BASE_FILE_EXTENSION))
+          .collect(Collectors.toSet());
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Adds a base file the metadata table has never heard of to a partition of 
the table.
+   *
+   * @param partition Partition to add the file to.
+   * @return The name of the file created.
+   */
+  private String writeStrayBaseFile(String partition) throws IOException {
+    String existing = baseFilesOf(partition).iterator().next();
+    // <fileId>_<writeToken>_<instantTime>.parquet, only the file id has to be 
new
+    String strayFile = UUID.randomUUID() + 
existing.substring(existing.indexOf('_'));
+    Files.createFile(Paths.get(tablePath, partition, strayFile));
+    return strayFile;
+  }
+
+  private static void clearMetadataBaseDirectory() throws Exception {
+    Field field = 
MetadataCommand.class.getDeclaredField("metadataBaseDirectory");
+    field.setAccessible(true);
+    field.set(null, null);
+  }
+
   private void validateRecordIndexOutput(String recordKey, Option<String> 
partitionPathOp,
                                          String expectedInstantTime, String 
expectedPartitionPath) {
     // Execute the metadata lookup-record-index command
diff --git 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestRollbacksCommand.java 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestRollbacksCommand.java
index 951213b856ed..304459bcf730 100644
--- 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestRollbacksCommand.java
+++ 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestRollbacksCommand.java
@@ -61,6 +61,7 @@ import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_P
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
 import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_THIRD_PARTITION_PATH;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -74,10 +75,12 @@ public class TestRollbacksCommand extends 
CLIFunctionalTestHarness {
   @Autowired
   private Shell shell;
 
+  private String tablePath;
+
   @BeforeEach
   public void init() throws Exception {
     String tableName = tableName();
-    String tablePath = tablePath(tableName);
+    tablePath = tablePath(tableName);
     new TableCommand().createTable(
         tablePath, tableName, HoodieTableType.MERGE_ON_READ.name(),
         "", HoodieTableVersion.current().versionCode(), 
"org.apache.hudi.common.model.HoodieAvroPayload");
@@ -205,4 +208,38 @@ public class TestRollbacksCommand extends 
CLIFunctionalTestHarness {
     String got = removeNonWordAndStripSpace(result.toString());
     assertEquals(expected, got);
   }
+
+  /**
+   * Test case of the rollback entry point of {@link SparkMain}, which the 
'commit rollback'
+   * command reaches through a spark-submit of its own. The fixture leaves 
commit 101 in the
+   * inflight state, which is the failed write such a rollback is meant to 
clean up.
+   */
+  @Test
+  public void testSparkMainRollback() throws Exception {
+    HoodieTableMetaClient metaClient = HoodieCLI.getTableMetaClient();
+    HoodieActiveTimeline timeline = metaClient.reloadActiveTimeline();
+    
assertTrue(timeline.getCommitsTimeline().filterInflightsAndRequested().containsInstant("101"));
+    int rollbacksBefore = 
timeline.getRollbackTimeline().filterCompletedInstants().countInstants();
+
+    assertEquals(0, SparkMain.rollback(jsc(), "101", tablePath, false));
+
+    timeline = metaClient.reloadActiveTimeline();
+    assertFalse(timeline.getCommitsTimeline().containsInstant("101"));
+    assertEquals(rollbacksBefore + 1,
+        
timeline.getRollbackTimeline().filterCompletedInstants().countInstants());
+  }
+
+  /**
+   * An instant that is not on the timeline cannot be rolled back, and nothing 
on the timeline
+   * moves because of the attempt.
+   */
+  @Test
+  public void testSparkMainRollbackOfUnknownInstant() throws Exception {
+    HoodieTableMetaClient metaClient = HoodieCLI.getTableMetaClient();
+    List<HoodieInstant> before = 
metaClient.reloadActiveTimeline().getInstants();
+
+    assertEquals(-1, SparkMain.rollback(jsc(), "999", tablePath, false));
+
+    assertEquals(before, metaClient.reloadActiveTimeline().getInstants());
+  }
 }
diff --git 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestSavepointsCommand.java
 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestSavepointsCommand.java
index 4700b2366319..124686d9976a 100644
--- 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestSavepointsCommand.java
+++ 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestSavepointsCommand.java
@@ -23,11 +23,18 @@ import org.apache.hudi.cli.HoodiePrintHelper;
 import org.apache.hudi.cli.HoodieTableHeaderFields;
 import org.apache.hudi.cli.functional.CLIFunctionalTestHarness;
 import org.apache.hudi.cli.testutils.ShellEvaluationResultUtil;
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.HoodieTableVersion;
 import org.apache.hudi.common.table.timeline.HoodieTimeline;
 import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.testutils.Assertions;
 
+import org.apache.spark.api.java.JavaRDD;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
@@ -37,9 +44,13 @@ import org.springframework.shell.Shell;
 
 import java.io.IOException;
 import java.util.Comparator;
+import java.util.List;
 import java.util.stream.Stream;
 
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -117,4 +128,55 @@ public class TestSavepointsCommand extends 
CLIFunctionalTestHarness {
     // After refresh, there are 4 instants
     assertEquals(4, timeline.countInstants(), "there should have 4 instants");
   }
+
+  /**
+   * Test case of the savepoint entry points of {@link SparkMain}, which the 
savepoint commands
+   * reach through a spark-submit of their own.
+   */
+  @Test
+  public void testSparkMainSavepointLifecycle() throws Exception {
+    HoodieTestDataGenerator dataGen = new HoodieTestDataGenerator(new String[] 
{DEFAULT_FIRST_PARTITION_PATH});
+    HoodieWriteConfig config = HoodieWriteConfig.newBuilder()
+        .withPath(tablePath).withSchema(TRIP_EXAMPLE_SCHEMA).build();
+    String firstCommit;
+    String secondCommit;
+    try (SparkRDDWriteClient client = new SparkRDDWriteClient(context(), 
config)) {
+      firstCommit = writeInserts(client, dataGen);
+      secondCommit = writeInserts(client, dataGen);
+    }
+    HoodieTableMetaClient metaClient = HoodieCLI.getTableMetaClient();
+
+    assertEquals(0, SparkMain.createSavepoint(jsc(), firstCommit, "test-user", 
"test-comment", tablePath));
+    HoodieTimeline savepoints = 
metaClient.reloadActiveTimeline().getSavePointTimeline().filterCompletedInstants();
+    assertEquals(1, savepoints.countInstants());
+    assertEquals(firstCommit, savepoints.firstInstant().get().requestedTime());
+    assertEquals("test-user", 
savepoints.readSavepointMetadata(savepoints.firstInstant().get()).getSavepointedBy());
+
+    // a commit that is not on the timeline cannot be savepointed
+    assertEquals(-1, SparkMain.createSavepoint(jsc(), "001", "test-user", 
"test-comment", tablePath));
+    assertEquals(1, 
metaClient.reloadActiveTimeline().getSavePointTimeline().countInstants());
+
+    // restoring to the savepoint takes the commits made after it off the 
timeline
+    assertEquals(0, SparkMain.rollbackToSavepoint(jsc(), firstCommit, 
tablePath, false));
+    HoodieTimeline commits = 
metaClient.reloadActiveTimeline().getCommitsTimeline().filterCompletedInstants();
+    assertTrue(commits.containsInstant(firstCommit));
+    assertFalse(commits.containsInstant(secondCommit));
+    assertEquals(-1, SparkMain.rollbackToSavepoint(jsc(), "001", tablePath, 
false));
+
+    assertEquals(0, SparkMain.deleteSavepoint(jsc(), firstCommit, tablePath));
+    assertEquals(0, 
metaClient.reloadActiveTimeline().getSavePointTimeline().countInstants());
+    // deleting a savepoint that is already gone is a no-op rather than a 
failure
+    assertEquals(0, SparkMain.deleteSavepoint(jsc(), firstCommit, tablePath));
+    assertEquals(0, 
metaClient.reloadActiveTimeline().getSavePointTimeline().countInstants());
+  }
+
+  private String writeInserts(SparkRDDWriteClient client, 
HoodieTestDataGenerator dataGen) {
+    String commitTime = client.startCommit();
+    List<HoodieRecord> records = dataGen.generateInserts(commitTime, 10);
+    JavaRDD<HoodieRecord> writeRecords = 
context().getJavaSparkContext().parallelize(records, 1);
+    List<WriteStatus> result = client.upsert(writeRecords, 
commitTime).collect();
+    client.commit(commitTime, jsc().parallelize(result));
+    Assertions.assertNoWriteErrors(result);
+    return commitTime;
+  }
 }
diff --git 
a/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestTimelineCommand.java 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestTimelineCommand.java
new file mode 100644
index 000000000000..2b2f4ec96b0e
--- /dev/null
+++ 
b/hudi-cli/src/test/java/org/apache/hudi/cli/commands/TestTimelineCommand.java
@@ -0,0 +1,316 @@
+/*
+ * 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.
+ */
+
+package org.apache.hudi.cli.commands;
+
+import org.apache.hudi.avro.model.HoodieInstantInfo;
+import org.apache.hudi.avro.model.HoodieRollbackPlan;
+import org.apache.hudi.cli.HoodieCLI;
+import org.apache.hudi.cli.functional.CLIFunctionalTestHarness;
+import org.apache.hudi.cli.testutils.ShellEvaluationResultUtil;
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.testutils.HoodieMetadataTestTable;
+import org.apache.hudi.common.testutils.HoodieTestTable;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieIndexConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.index.HoodieIndex;
+import org.apache.hudi.metadata.HoodieTableMetadata;
+import org.apache.hudi.metadata.HoodieTableMetadataWriter;
+import org.apache.hudi.metadata.SparkHoodieBackedTableMetadataWriter;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.shell.Shell;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link TimelineCommand}.
+ */
+@Tag("functional")
+@SpringBootTest(properties = {"spring.shell.interactive.enabled=false", 
"spring.shell.command.script.enabled=false"})
+public class TestTimelineCommand extends CLIFunctionalTestHarness {
+
+  // Column offsets of the data table part of the rendered timeline, row 
number included.
+  private static final int COL_INSTANT = 1;
+  private static final int COL_ACTION = 2;
+  private static final int COL_STATE = 3;
+  private static final int COL_REQUESTED_TIME = 4;
+  private static final int COL_INFLIGHT_TIME = 5;
+  private static final int COL_COMPLETED_TIME = 6;
+  // Column offsets of the metadata table part, only rendered with 
--with-metadata-table.
+  private static final int COL_MT_ACTION = 7;
+  private static final int COL_MT_STATE = 8;
+
+  // The commit left in the requested state, and the rollback scheduled 
against it.
+  private static final String REQUESTED_COMMIT = "103";
+  private static final String PENDING_ROLLBACK_INSTANT = "104";
+  private static final String ROLLED_BACK_COMMIT = "102";
+
+  private static final String DATE_NO_SECONDS = "\\d{2}-\\d{2} \\d{2}:\\d{2}";
+  private static final String DATE_WITH_SECONDS = "\\d{2}-\\d{2} 
\\d{2}:\\d{2}:\\d{2}";
+
+  @Autowired
+  private Shell shell;
+
+  private String tablePath;
+  private HoodieTableMetaClient metaClient;
+  private String rollbackInstantTime;
+
+  /**
+   * Builds a table whose active timeline holds two completed commits, a 
completed rollback of a
+   * third commit, one commit left in the requested state and a rollback 
scheduled against that
+   * commit, with the metadata table enabled so that the metadata table 
timeline is populated too.
+   */
+  @BeforeEach
+  public void init() throws Exception {
+    HoodieCLI.conf = storageConf();
+    String tableName = tableName();
+    tablePath = tablePath(tableName);
+
+    createTableAndConnect(tablePath, tableName, HoodieTableType.COPY_ON_WRITE, 
HoodieAvroPayload.class.getName());
+    metaClient = HoodieTableMetaClient.reload(HoodieCLI.getTableMetaClient());
+
+    Map<String, String> partitionAndFileId = new HashMap<>();
+    partitionAndFileId.put(DEFAULT_FIRST_PARTITION_PATH, "file-1");
+    partitionAndFileId.put(DEFAULT_SECOND_PARTITION_PATH, "file-2");
+
+    HoodieWriteConfig config = 
HoodieWriteConfig.newBuilder().withPath(tablePath)
+        .withMetadataConfig(
+            // Column Stats Index is disabled, since this table is built with 
empty commit metadata
+            
HoodieMetadataConfig.newBuilder().withMetadataIndexColumnStats(false).build())
+        .withRollbackUsingMarkers(false)
+        
.withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.INMEMORY).build())
+        .build();
+
+    try (HoodieTableMetadataWriter metadataWriter = 
SparkHoodieBackedTableMetadataWriter.create(
+        metaClient.getStorageConf(), config, context)) {
+      HoodieTestTable testTable = HoodieMetadataTestTable.of(metaClient, 
metadataWriter, Option.of(context))
+          .withPartitionMetaFiles(DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH)
+          
.addCommit("100").withBaseFilesInPartitions(partitionAndFileId).getLeft()
+          
.addCommit("101").withBaseFilesInPartitions(partitionAndFileId).getLeft()
+          .addInflightCommit(ROLLED_BACK_COMMIT);
+      testTable.withBaseFilesInPartitions(partitionAndFileId);
+
+      try (SparkRDDWriteClient client = new SparkRDDWriteClient(context(), 
config)) {
+        client.rollback(ROLLED_BACK_COMMIT);
+      }
+      // left behind on the timeline so that the incomplete timeline is not 
empty
+      testTable.addRequestedCommit(REQUESTED_COMMIT);
+
+      // A rollback that is scheduled but has not run yet. The completed 
rollback above deleted the
+      // commit it targeted, so on this table the scheduled one is what makes 
the "rolled back by"
+      // annotation reachable: it leaves its target on the timeline. Added 
straight through the test
+      // table so that it stays on the data table timeline only.
+      HoodieRollbackPlan rollbackPlan = new HoodieRollbackPlan();
+      rollbackPlan.setRollbackRequests(Collections.emptyList());
+      rollbackPlan.setInstantToRollback(new 
HoodieInstantInfo(REQUESTED_COMMIT, HoodieTimeline.COMMIT_ACTION));
+      testTable.addRequestedRollback(PENDING_ROLLBACK_INSTANT, rollbackPlan);
+      testTable.addInflightRollback(PENDING_ROLLBACK_INSTANT);
+    }
+
+    HoodieCLI.refreshTableMetadata();
+    metaClient = HoodieCLI.getTableMetaClient();
+    rollbackInstantTime = metaClient.getActiveTimeline().getRollbackTimeline()
+        .filterCompletedInstants().lastInstant().get().requestedTime();
+  }
+
+  @Test
+  public void testShowActive() {
+    Object result = shell.evaluate(() -> "timeline show active");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+    List<List<String>> rows = renderedRows(result.toString());
+    assertEquals(instantTimes(metaClient), rows.stream().map(r -> 
r.get(COL_INSTANT)).collect(Collectors.toSet()));
+    assertEquals(metaClient.getActiveTimeline().countInstants(), rows.size());
+
+    List<String> commit100 = rowOf(rows, "100");
+    assertEquals("commit", commit100.get(COL_ACTION));
+    assertEquals(HoodieInstant.State.COMPLETED.toString(), 
commit100.get(COL_STATE));
+    // a completed commit has all three instant files, so all three 
modification times are rendered
+    assertTrue(commit100.get(COL_REQUESTED_TIME).matches(DATE_NO_SECONDS), 
commit100.toString());
+    assertTrue(commit100.get(COL_INFLIGHT_TIME).matches(DATE_NO_SECONDS), 
commit100.toString());
+    assertTrue(commit100.get(COL_COMPLETED_TIME).matches(DATE_NO_SECONDS), 
commit100.toString());
+
+    List<String> commit103 = rowOf(rows, REQUESTED_COMMIT);
+    assertEquals("commit", commit103.get(COL_ACTION));
+    assertEquals(HoodieInstant.State.REQUESTED.toString(), 
commit103.get(COL_STATE));
+    // only the requested file exists for it, the other two states render as a 
dash
+    assertTrue(commit103.get(COL_REQUESTED_TIME).matches(DATE_NO_SECONDS), 
commit103.toString());
+    assertEquals("-", commit103.get(COL_INFLIGHT_TIME));
+    assertEquals("-", commit103.get(COL_COMPLETED_TIME));
+
+    assertEquals("rollback", rowOf(rows, rollbackInstantTime).get(COL_ACTION));
+  }
+
+  @Test
+  public void testShowActiveWithLimitAndSorting() {
+    Object result = shell.evaluate(() -> "timeline show active --limit 2 
--sortBy Instant --desc true");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+    List<List<String>> rows = renderedRows(result.toString());
+    assertEquals(2, rows.size());
+    List<String> allInstants = new ArrayList<>(instantTimes(metaClient));
+    allInstants.sort(String::compareTo);
+    // descending order on the instant time, cut to the first two rows
+    assertEquals(allInstants.get(allInstants.size() - 1), 
rows.get(0).get(COL_INSTANT));
+    assertEquals(allInstants.get(allInstants.size() - 2), 
rows.get(1).get(COL_INSTANT));
+  }
+
+  @Test
+  public void testShowActiveHeaderOnly() {
+    Object result = shell.evaluate(() -> "timeline show active --headeronly 
true");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+    assertTrue(renderedRows(result.toString()).isEmpty(), result.toString());
+    assertTrue(result.toString().contains("Instant"), result.toString());
+    assertTrue(result.toString().contains(EMPTY_TABLE_CELL), 
result.toString());
+  }
+
+  @Test
+  public void testShowActiveWithRollbackInfoAndSeconds() {
+    Object result = shell.evaluate(
+        () -> "timeline show active --show-rollback-info true 
--show-time-seconds true");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+    List<List<String>> rows = renderedRows(result.toString());
+    // the completed rollback is annotated with the commit it rolled back, 
read from its metadata
+    assertEquals("rollback Rolls back " + ROLLED_BACK_COMMIT, rowOf(rows, 
rollbackInstantTime).get(COL_ACTION));
+    // the scheduled one with the commit its plan targets
+    assertEquals("rollback Rolls back " + REQUESTED_COMMIT,
+        rowOf(rows, PENDING_ROLLBACK_INSTANT).get(COL_ACTION));
+    // and that commit is annotated back with the rollback scheduled against it
+    assertEquals("commit Rolled back by " + PENDING_ROLLBACK_INSTANT,
+        rowOf(rows, REQUESTED_COMMIT).get(COL_ACTION));
+    // instants that no rollback refers to carry no annotation
+    assertEquals("commit", rowOf(rows, "100").get(COL_ACTION));
+    assertTrue(rowOf(rows, 
"100").get(COL_COMPLETED_TIME).matches(DATE_WITH_SECONDS),
+        rowOf(rows, "100").toString());
+  }
+
+  @Test
+  public void testShowIncomplete() {
+    Object result = shell.evaluate(() -> "timeline show incomplete");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+    List<List<String>> rows = renderedRows(result.toString());
+    assertEquals(2, rows.size(), result.toString());
+
+    List<String> requestedCommit = rowOf(rows, REQUESTED_COMMIT);
+    assertEquals("commit", requestedCommit.get(COL_ACTION));
+    assertEquals(HoodieInstant.State.REQUESTED.toString(), 
requestedCommit.get(COL_STATE));
+    assertEquals("-", requestedCommit.get(COL_COMPLETED_TIME));
+
+    List<String> pendingRollback = rowOf(rows, PENDING_ROLLBACK_INSTANT);
+    assertEquals("rollback", pendingRollback.get(COL_ACTION));
+    assertEquals(HoodieInstant.State.INFLIGHT.toString(), 
pendingRollback.get(COL_STATE));
+    assertEquals("-", pendingRollback.get(COL_COMPLETED_TIME));
+  }
+
+  @Test
+  public void testShowActiveWithMetadataTable() {
+    Object result = shell.evaluate(
+        () -> "timeline show active --with-metadata-table true 
--show-rollback-info true --limit 50");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+    HoodieTableMetaClient mtMetaClient = metadataTableMetaClient();
+    Set<String> expected = instantTimes(metaClient);
+    expected.addAll(instantTimes(mtMetaClient));
+
+    List<List<String>> rows = renderedRows(result.toString());
+    assertEquals(expected, rows.stream().map(r -> 
r.get(COL_INSTANT)).collect(Collectors.toSet()));
+
+    // the data table columns of a data table only instant, and its empty 
metadata table columns
+    List<String> commit103 = rowOf(rows, REQUESTED_COMMIT);
+    assertEquals("commit Rolled back by " + PENDING_ROLLBACK_INSTANT, 
commit103.get(COL_ACTION));
+    assertEquals("-", commit103.get(COL_MT_ACTION));
+    assertEquals("-", commit103.get(COL_MT_STATE));
+
+    // every metadata table instant is rendered with its action and state in 
the metadata columns
+    for (HoodieInstant instant : 
mtMetaClient.getActiveTimeline().getInstants()) {
+      List<String> row = rowOf(rows, instant.requestedTime());
+      assertEquals(instant.getAction(), row.get(COL_MT_ACTION));
+      assertEquals(instant.getState().toString(), row.get(COL_MT_STATE));
+    }
+  }
+
+  @Test
+  public void testMetadataShowActive() {
+    Object result = shell.evaluate(() -> "metadata timeline show active 
--limit 50");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+    HoodieTableMetaClient mtMetaClient = metadataTableMetaClient();
+    List<List<String>> rows = renderedRows(result.toString());
+    assertFalse(rows.isEmpty(), result.toString());
+    assertEquals(instantTimes(mtMetaClient), rows.stream().map(r -> 
r.get(COL_INSTANT)).collect(Collectors.toSet()));
+    for (HoodieInstant instant : 
mtMetaClient.getActiveTimeline().getInstants()) {
+      List<String> row = rowOf(rows, instant.requestedTime());
+      assertEquals(instant.getAction(), row.get(COL_ACTION));
+      assertEquals(instant.getState().toString(), row.get(COL_STATE));
+    }
+  }
+
+  @Test
+  public void testMetadataShowIncomplete() {
+    Object result = shell.evaluate(() -> "metadata timeline show incomplete");
+    assertTrue(ShellEvaluationResultUtil.isSuccess(result));
+
+    // the metadata table is written synchronously, so nothing is left 
incomplete on its timeline
+    assertEquals(0, 
metadataTableMetaClient().getActiveTimeline().filterInflightsAndRequested().countInstants());
+    assertTrue(renderedRows(result.toString()).isEmpty(), result.toString());
+  }
+
+  private HoodieTableMetaClient metadataTableMetaClient() {
+    return HoodieTableMetaClient.builder()
+        .setConf(HoodieCLI.conf.newInstance())
+        .setBasePath(HoodieTableMetadata.getMetadataTableBasePath(tablePath))
+        .build();
+  }
+
+  private static Set<String> instantTimes(HoodieTableMetaClient metaClient) {
+    return 
HoodieTableMetaClient.reload(metaClient).getActiveTimeline().getInstantsAsStream()
+        .map(HoodieInstant::requestedTime).collect(Collectors.toSet());
+  }
+
+  private static List<String> rowOf(List<List<String>> rows, String 
instantTime) {
+    return rows.stream().filter(r -> 
r.get(COL_INSTANT).equals(instantTime)).findFirst()
+        .orElseThrow(() -> new AssertionError("No rendered row for instant " + 
instantTime + " in " + rows));
+  }
+}
diff --git 
a/hudi-cli/src/test/java/org/apache/hudi/cli/functional/CLIFunctionalTestHarness.java
 
b/hudi-cli/src/test/java/org/apache/hudi/cli/functional/CLIFunctionalTestHarness.java
index 17584caa3d8a..8bde9cdd06ad 100644
--- 
a/hudi-cli/src/test/java/org/apache/hudi/cli/functional/CLIFunctionalTestHarness.java
+++ 
b/hudi-cli/src/test/java/org/apache/hudi/cli/functional/CLIFunctionalTestHarness.java
@@ -19,9 +19,14 @@
 
 package org.apache.hudi.cli.functional;
 
+import org.apache.hudi.cli.HoodieCLI;
+import org.apache.hudi.cli.commands.TableCommand;
 import org.apache.hudi.client.SparkRDDReadClient;
 import org.apache.hudi.client.common.HoodieSparkEngineContext;
+import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
 import org.apache.hudi.common.table.view.FileSystemViewStorageConfig;
 import org.apache.hudi.hadoop.fs.HadoopFSUtils;
 import org.apache.hudi.storage.StorageConfiguration;
@@ -41,11 +46,20 @@ import org.junit.jupiter.api.io.TempDir;
 
 import java.io.IOException;
 import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
 
 public class CLIFunctionalTestHarness implements SparkProvider {
 
   protected static final String BASE_FILE_EXTENSION = 
HoodieTableConfig.BASE_FILE_FORMAT.defaultValue().getFileExtension();
 
+  // Box drawing characters of a rendered table, kept as escapes so that the 
source stays ASCII.
+  private static final char TABLE_ROW_START = '\u2551'; // double vertical, 
starts and ends a row
+  private static final char TABLE_HEADER_DIVIDER = '\u2560'; // double 
vertical and right, under the header
+  private static final char TABLE_ROW_DIVIDER = '\u255F'; // double vertical 
and single right, between rows
+  private static final String TABLE_CELL_SEPARATORS = "[\u2551\u2502]"; // 
double and single vertical
+  protected static final String EMPTY_TABLE_CELL = "(empty)";
+
   protected static int timelineServicePort =
       FileSystemViewStorageConfig.REMOTE_PORT_NUM.defaultValue();
   protected static transient TimelineService timelineService;
@@ -139,6 +153,91 @@ public class CLIFunctionalTestHarness implements 
SparkProvider {
     return str.replaceAll("[\\s]+", ",").replaceAll("[\\W]+", ",");
   }
 
+  /**
+   * Initializes a table and connects the CLI to it, which is what the 
'create' command does once
+   * its check for an already existing table comes back empty. That check is 
what this skips: on a
+   * path that holds no table it spends five seconds in the hoodie.properties 
read retry loop (five
+   * attempts, one second apart) before it concludes there is nothing there, 
and a fixture creating
+   * its own table knows that already.
+   *
+   * @param tablePath    Base path of the table to create.
+   * @param tableName    Name of the table.
+   * @param tableType    Type of the table.
+   * @param payloadClass Payload class of the table.
+   */
+  protected void createTableAndConnect(String tablePath, String tableName, 
HoodieTableType tableType,
+                                       String payloadClass) throws IOException 
{
+    boolean initialized = HoodieCLI.initConf();
+    HoodieCLI.initFS(initialized);
+    HoodieTableMetaClient.newTableBuilder()
+        .setTableType(tableType.name())
+        .setTableName(tableName)
+        .setPayloadClassName(payloadClass)
+        .setTableVersion(HoodieTableVersion.current().versionCode())
+        .initTable(HoodieCLI.conf.newInstance(), tablePath);
+    new TableCommand().connect(tablePath, false, 0, 0, 0, 
"WAIT_TO_ADJUST_SKEW", 200L, true);
+  }
+
+  /**
+   * Splits a table rendered by {@link org.apache.hudi.cli.HoodiePrintHelper} 
into its data rows,
+   * each row being the list of its trimmed cell values. Cells spanning 
several rendered lines are
+   * joined back into a single cell, separated by a space. The header and an 
empty table yield no
+   * rows.
+   *
+   * @param rendered Rendered table.
+   * @return One list of cell values per data row.
+   */
+  protected static List<List<String>> renderedRows(String rendered) {
+    List<List<String>> rows = new ArrayList<>();
+    boolean inData = false;
+    boolean startOfRow = false;
+    for (String line : rendered.split("\n")) {
+      if (line.isEmpty()) {
+        continue;
+      }
+      char first = line.charAt(0);
+      if (first == TABLE_HEADER_DIVIDER || first == TABLE_ROW_DIVIDER) {
+        inData = true;
+        startOfRow = true;
+        continue;
+      }
+      if (first != TABLE_ROW_START || !inData) {
+        continue;
+      }
+      List<String> cells = renderedCells(line);
+      if (cells.size() == 1 && EMPTY_TABLE_CELL.equals(cells.get(0))) {
+        continue;
+      }
+      if (startOfRow) {
+        rows.add(cells);
+        startOfRow = false;
+      } else {
+        List<String> previous = rows.get(rows.size() - 1);
+        for (int i = 0; i < cells.size(); i++) {
+          if (!cells.get(i).isEmpty()) {
+            previous.set(i, (previous.get(i) + " " + cells.get(i)).trim());
+          }
+        }
+      }
+    }
+    return rows;
+  }
+
+  /**
+   * Splits a single rendered line into its trimmed cell values.
+   *
+   * @param line One line of a rendered table.
+   * @return The cell values of that line.
+   */
+  protected static List<String> renderedCells(String line) {
+    String[] parts = line.split(TABLE_CELL_SEPARATORS, -1);
+    List<String> cells = new ArrayList<>();
+    for (int i = 1; i < parts.length - 1; i++) {
+      cells.add(parts[i].trim());
+    }
+    return cells;
+  }
+
   protected int incrementTimelineServicePortToUse() {
     // Increment the timeline service port for each individual test
     // to avoid port reuse causing failures

Reply via email to