ProjectRepairer: Add a --copy-loose-objects repair action

Add a COPY_LOOSE_OBJECTS action, exposed as '--copy-loose-objects',
rsyncing the two hex character fanout directories under objects/. The
include patterns keep rsync out of pack/, info/ and their siblings.

The action is declared ahead of COPY_PACKS, so when both are selected
loose objects are copied first. '--full' now covers both.

Change-Id: I9c3d12182bdc5fa9da662269d8681db174057f21
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ProjectRepairer.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ProjectRepairer.java
index 317e6b4..887a464 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ProjectRepairer.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ProjectRepairer.java
@@ -39,6 +39,7 @@
 @Singleton
 public class ProjectRepairer {
   public enum Action {
+    COPY_LOOSE_OBJECTS,
     COPY_PACKS;
 
     public static List<Action> all() {
@@ -46,7 +47,8 @@
     }
   }
 
-  private static final String PACK_DIR = "objects/pack/";
+  private static final String OBJECTS_DIR = "objects/";
+  private static final String PACK_DIR = OBJECTS_DIR + "pack/";
 
   private final GitRepositoryManager gitManager;
   private final ReplicationConfig replicationConfig;
@@ -81,6 +83,7 @@
       throws InterruptedIOException {
     boolean isRepaired =
         switch (action) {
+          case COPY_LOOSE_OBJECTS -> copyLooseObjectsTo(objectsDir, uri, out);
           case COPY_PACKS -> copyPacksTo(objectsDir.resolve("pack"), uri, out);
         };
     if (!isRepaired) {
@@ -112,6 +115,16 @@
     }
   }
 
+  private boolean copyLooseObjectsTo(Path objectsDir, URIish uri, OutputStream out)
+      throws InterruptedIOException {
+    if (!Files.isDirectory(objectsDir)) {
+      repLog.atSevere().log("No objects directory %s", objectsDir);
+      return false;
+    }
+
+    return copy(objectsDir, uri, out, OBJECTS_DIR, "/??/", "/??/*") == 0;
+  }
+
   private boolean copyPacksTo(Path packDir, URIish uri, OutputStream out)
       throws InterruptedIOException {
     if (!Files.isDirectory(packDir)) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
index 6e04f8a..7aa7e94 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
@@ -52,6 +52,13 @@
   private final List<Action> actions = new ArrayList<>();
 
   @Option(
+      name = "--copy-loose-objects",
+      usage = "rsync loose object files to SSH destinations before triggering replication")
+  void setCopyLooseObjects(@SuppressWarnings("unused") boolean arg) {
+    actions.add(Action.COPY_LOOSE_OBJECTS);
+  }
+
+  @Option(
       name = "--copy-packs",
       usage = "rsync objects/pack files to SSH destinations before triggering replication")
   void setCopyPacks(@SuppressWarnings("unused") boolean arg) {
diff --git a/src/main/resources/Documentation/cmd-repair.md b/src/main/resources/Documentation/cmd-repair.md
index 3f95907..ca20350 100644
--- a/src/main/resources/Documentation/cmd-repair.md
+++ b/src/main/resources/Documentation/cmd-repair.md
@@ -11,7 +11,7 @@
 ```console
 ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair
   [--url <PATTERN>]
-  [--full | --copy-packs]
+  [--full | [--copy-loose-objects] [--copy-packs]]
   <PROJECT>
 ```
 
@@ -24,6 +24,13 @@
 
 If no repair action flag is supplied, `--full` is assumed.
 
+For each remote, [remote.NAME.adminUrl](config.md#remote.NAME.adminUrl) is
+preferred when set (same as repository creation); otherwise
+[remote.NAME.url](config.md#remote.NAME.url) is used. Only plain SSH
+destinations are eligible (for example `user@host:/path/to/repo.git`).
+Destinations whose URL uses `gerrit+ssh`, HTTP(S), or a local path are
+skipped.
+
 REQUIREMENTS
 ------------
 The Gerrit runtime user must have `ssh` on `PATH`, plus `rsync` either on
@@ -51,15 +58,13 @@
 `--full`
 : Run every supported repair action.
 
+`--copy-loose-objects`
+: rsync the loose objects. Everything else under `objects/`, such as
+`pack/` and `info/`, is left untouched.
+
 `--copy-packs`
 : rsync regular files in `objects/pack/` whose names end with `.pack`,
-`.idx`, `.bitmap`, or `.rev` to each matching destination. For each
-remote, [remote.NAME.adminUrl](config.md#remote.NAME.adminUrl) is preferred
-when set (same as repository creation); otherwise
-[remote.NAME.url](config.md#remote.NAME.url) is used. Only plain SSH
-destinations are eligible (for example `user@host:/path/to/repo.git`).
-Destinations whose URL uses `gerrit+ssh`, HTTP(S), or a local path are
-skipped.
+`.idx`, `.bitmap`, or `.rev` to each matching destination.
 
 `PROJECT`
 : Exact Gerrit project name.
@@ -86,6 +91,12 @@
   $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair --copy-packs tools/gerrit
 ```
 
+Only copy loose objects:
+
+```console
+  $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair --copy-loose-objects tools/gerrit
+```
+
 Repair only against destinations whose URL mentions `replica1`:
 
 ```console
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 76a1858..a2f5459 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -276,9 +276,10 @@
 
 replication.rsyncPath
 :	Path to the `rsync` binary on the host running Gerrit, used by the
-	`@PLUGIN@ repair --copy-packs` command when transferring pack files
-	to SSH destinations. Set this when the Gerrit runtime user's `PATH`
-	does not contain `rsync`, or to pin a specific build.
+	[`@PLUGIN@ repair`](cmd-repair.md) command when transferring loose
+	objects and pack files to SSH destinations. Set this when the Gerrit
+	runtime user's `PATH` does not contain `rsync`, or to pin a specific
+	build.
 
 	Default: `rsync` (resolved via the Gerrit runtime user's `PATH`)