Add SSH command to repair projects Introduce a new 'replication repair PROJECT --copy-packs' SSH command that copies the pack files for a single project to desired destinations and then triggers a 'replication start'. Change-Id: Ic9f55e42f195a1261ae076c9fdc9a320b75b292a
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java index 5e2c758..0c77b16 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
@@ -125,6 +125,11 @@ } @Override + public String getRsyncPath() { + return currentConfig.getRsyncPath(); + } + + @Override public Config getConfig() { return currentConfig.getConfig(); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java index 9c1bfba..273f7e8 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -910,7 +910,7 @@ return config.getGitPath(); } - private static boolean matches(URIish uri, String urlMatch) { + static boolean matches(URIish uri, String urlMatch) { if (urlMatch == null || urlMatch.equals("") || urlMatch.equals("*")) { return true; }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java index 471a408..6eb4d3f 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -30,6 +30,7 @@ import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import com.google.common.flogger.FluentLogger; +import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Project; import com.google.gerrit.server.git.WorkQueue; import com.google.inject.Inject; @@ -83,7 +84,10 @@ @Override public Multimap<Destination, URIish> getURIs( - Optional<String> remoteName, Project.NameKey projectName, FilterType filterType) { + Optional<String> remoteName, + Project.NameKey projectName, + FilterType filterType, + @Nullable String urlMatch) { if (getAll(filterType).isEmpty()) { return ImmutableMultimap.of(); } @@ -113,6 +117,7 @@ continue; } + boolean matchesConfigUrl = Destination.matches(uri, urlMatch); if (!isGerrit(uri) && !isGerritHttp(uri)) { String path = replaceName(uri.getPath(), projectName.get(), config.isSingleProjectMatch()); @@ -128,12 +133,14 @@ continue; } } - uris.put(config, uri); - adminURLUsed = true; + if (matchesConfigUrl || Destination.matches(uri, urlMatch)) { + uris.put(config, uri); + adminURLUsed = true; + } } if (!adminURLUsed) { - for (URIish uri : config.getURIs(projectName, "*")) { + for (URIish uri : config.getURIs(projectName, urlMatch)) { uris.put(config, uri); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java index 6ed47d4..8d279d5 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
@@ -90,11 +90,17 @@ return sb.toString(); } + public interface SshOutputCommand { + void writeStdOutSync(String message); + + void writeStdErrSync(String message); + } + public static class CommandProcessing implements PushResultProcessing { - private WeakReference<StartCommand> sshCommand; + private WeakReference<SshOutputCommand> sshCommand; private AtomicBoolean hasError = new AtomicBoolean(); - CommandProcessing(StartCommand sshCommand) { + CommandProcessing(SshOutputCommand sshCommand) { this.sshCommand = new WeakReference<>(sshCommand); } @@ -162,7 +168,7 @@ @Override public void writeStdOut(String message) { - StartCommand command = sshCommand.get(); + SshOutputCommand command = sshCommand.get(); if (command != null) { command.writeStdOutSync(message); } @@ -170,7 +176,7 @@ @Override public void writeStdErr(String message) { - StartCommand command = sshCommand.get(); + SshOutputCommand command = sshCommand.get(); if (command != null) { command.writeStdErrSync(message); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java new file mode 100644 index 0000000..b70bb0f --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RepairCommand.java
@@ -0,0 +1,269 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.plugins.replication; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Project; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.extensions.annotations.RequiresCapability; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.project.ProjectCache; +import com.google.gerrit.sshd.CommandMetaData; +import com.google.gerrit.sshd.SshCommand; +import com.google.inject.Inject; +import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.URIish; +import org.eclipse.jgit.util.QuotedString; +import org.eclipse.jgit.util.io.StreamCopyThread; +import org.kohsuke.args4j.Argument; +import org.kohsuke.args4j.Option; + +@RequiresCapability(StartReplicationCapability.START_REPLICATION) +@CommandMetaData(name = "repair", description = "Repair a project on replication destinations") +final class RepairCommand extends SshCommand implements PushResultProcessing.SshOutputCommand { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + @Argument(index = 0, required = true, metaVar = "PROJECT", usage = "project name") + private String projectName; + + @Option( + name = "--url", + metaVar = "SUBSTRING", + usage = "substring URL must match (or * to match everything)") + private String urlMatch; + + @Option( + name = "--copy-packs", + usage = "rsync objects/pack files to SSH destinations before triggering replication") + private boolean copyPacks; + + @Option(name = "--full", usage = "run all supported repair actions (default)") + private boolean full; + + @Inject private GitRepositoryManager gitManager; + @Inject private ProjectCache projectCache; + @Inject private ReplicationDestinations destinations; + @Inject private ReplicationStarter replicationStarter; + @Inject private ReplicationConfig replicationConfig; + + private final Object outputLock = new Object(); + + @Override + protected void run() throws Failure { + Project.NameKey project = Project.nameKey(projectName); + try { + if (projectCache.get(project).isEmpty()) { + throw die("Project with name " + projectName + " not found."); + } + } catch (StorageException e) { + throw die(e); + } + + if (!copyPacks) { + full = true; + } + + Set<URIish> failedUris = new HashSet<>(); + if (full || copyPacks) { + failedUris.addAll(copyPacks(project)); + } + + if (!failedUris.isEmpty()) { + throw new UnloggedFailure(1, "Repair failed for " + failedUris.size() + " destination(s)"); + } + + writeStdOutSync("\nRunning replication start for " + project.get() + " ..."); + replicationStarter.start( + urlMatch, + Set.of(), + new ReplicationFilter(List.of(project.get())), + /* now= */ true, + /* wait= */ true, + this); + } + + private Set<URIish> copyPacks(Project.NameKey project) throws Failure { + Path packDir; + try (Repository repo = gitManager.openRepository(project)) { + packDir = repo.getDirectory().toPath().resolve("objects").resolve("pack"); + } catch (IOException e) { + throw die(e); + } + + if (!Files.isDirectory(packDir)) { + throw die("No objects/pack directory for project " + projectName); + } + + Set<URIish> copyTargets = new HashSet<>(); + Collection<URIish> destUris = + destinations + .getURIs(Optional.empty(), project, ReplicationConfig.FilterType.ALL, urlMatch) + .values(); + for (URIish uri : destUris) { + if (!canCopy(uri)) { + writeStdErrSync( + "Warning: skipping " + uri + " as copy-packs only supports plain SSH destinations"); + continue; + } + copyTargets.add(uri); + } + + if (copyTargets.isEmpty()) { + throw die("No matching destinations found"); + } + + Set<URIish> failedUris = new HashSet<>(); + for (URIish uri : copyTargets) { + writeStdOutSync("Copying pack files to " + uri + " ..."); + if (!copyInOrder(packDir, uri)) { + failedUris.add(uri); + } + } + return failedUris; + } + + private boolean copyInOrder(Path packDir, URIish uri) throws Failure { + return copyWithDie(packDir, uri, "*.pack") + && copyWithDie(packDir, uri, "*.idx", "*.bitmap", "*.rev"); + } + + private boolean copyWithDie(Path src, URIish uri, String... includes) throws Failure { + int retCode; + try { + retCode = copy(src, uri, includes); + } catch (IOException e) { + throw die(e); + } catch (InterruptedException e) { + throw die("Interrupted during copy to " + uri, e); + } + if (retCode != 0) { + writeStdErrSync("Warning: copy to " + uri + " failed with exit code " + retCode); + return false; + } + return true; + } + + private static boolean canCopy(URIish uri) { + return AdminApiFactory.isSSH(uri) && !AdminApiFactory.isGerrit(uri); + } + + private int copy(Path src, URIish uri, String... includes) + throws IOException, InterruptedException, UnloggedFailure { + List<String> cmd = new ArrayList<>(); + cmd.add(replicationConfig.getRsyncPath()); + cmd.add("-avP"); + cmd.add("-e"); + cmd.add(buildSshTransport(uri)); + for (String inc : includes) { + cmd.add("--include=" + inc); + } + cmd.add("--exclude=*"); + cmd.add(src.toAbsolutePath().normalize() + "/"); + cmd.add(buildCopyDestination(uri)); + + logger.atInfo().log("Running repair cmd: %s", String.join(" ", cmd)); + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process p = pb.start(); + + stdout.flush(); + StreamCopyThread outStream = + new StreamCopyThread(p.getInputStream(), getFlushingOutputStream()); + outStream.setName("copy-packs-output"); + outStream.start(); + try { + int code = p.waitFor(); + outStream.join(); + return code; + } catch (InterruptedException e) { + p.destroyForcibly(); + outStream.halt(); + throw e; + } + } + + private OutputStream getFlushingOutputStream() { + return new OutputStream() { + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + out.flush(); + } + + @Override + public void write(int b) throws IOException { + out.write(b); + out.flush(); + } + }; + } + + private String buildCopyDestination(URIish uri) throws UnloggedFailure { + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + throw die("URI has no host: " + uri); + } + + String path = uri.getPath(); + if (path == null || path.isEmpty()) { + throw die("URI has no path: " + uri); + } + + String remotePackPath = QuotedString.BOURNE.quote(path + "/objects/pack/"); + + String user = uri.getUser(); + if (user != null && !user.isEmpty()) { + return user + "@" + host + ":" + remotePackPath; + } + return host + ":" + remotePackPath; + } + + private static String buildSshTransport(URIish uri) { + StringBuilder sb = new StringBuilder("ssh -o BatchMode=yes"); + int port = uri.getPort(); + if (port > 0) { + sb.append(" -p ").append(port); + } + return sb.toString(); + } + + @Override + public void writeStdOutSync(String message) { + synchronized (outputLock) { + stdout.println(message); + stdout.flush(); + } + } + + @Override + public void writeStdErrSync(String message) { + synchronized (outputLock) { + stderr.println(message); + stderr.flush(); + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java index 8f9b805..f625232 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
@@ -28,6 +28,7 @@ public class ReplicationConfigImpl implements ReplicationConfig { private static final int DEFAULT_SSH_CONNECTION_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes + private static final String DEFAULT_RSYNC_PATH = "rsync"; private final SitePaths site; private final MergedConfigResource configResource; @@ -145,4 +146,10 @@ public int getSshCommandTimeout() { return sshCommandTimeout; } + + @Override + public String getRsyncPath() { + String rsyncPath = getConfig().getString("replication", null, "rsyncPath"); + return Strings.isNullOrEmpty(rsyncPath) ? DEFAULT_RSYNC_PATH : rsyncPath; + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java index bcc07e5..91ced84 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationDestinations.java
@@ -15,6 +15,7 @@ package com.googlesource.gerrit.plugins.replication; import com.google.common.collect.Multimap; +import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Project; import com.google.gerrit.server.git.WorkQueue; import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig.FilterType; @@ -32,10 +33,19 @@ * @param remoteName name of the replication end or empty if selecting all ends. * @param projectName name of the project * @param filterType type of filter criteria for selecting projects + * @param urlMatch optional substring filter on configuration or expanded URLs; null matches all * @return the multi-map of destinations and the associated replication URIs */ Multimap<Destination, URIish> getURIs( - Optional<String> remoteName, Project.NameKey projectName, FilterType filterType); + Optional<String> remoteName, + Project.NameKey projectName, + FilterType filterType, + @Nullable String urlMatch); + + default Multimap<Destination, URIish> getURIs( + Optional<String> remoteName, Project.NameKey projectName, FilterType filterType) { + return getURIs(remoteName, projectName, filterType, null); + } /** * List of currently active replication destinations.
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java new file mode 100644 index 0000000..b7baab9 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationStarter.java
@@ -0,0 +1,77 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.plugins.replication; + +import com.google.gerrit.common.Nullable; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.googlesource.gerrit.plugins.replication.PushResultProcessing.CommandProcessing; +import com.googlesource.gerrit.plugins.replication.PushResultProcessing.SshOutputCommand; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +@Singleton +class ReplicationStarter { + private final PushAll.Factory pushFactory; + private final ReplicationStateLogger stateLog; + + @Inject + ReplicationStarter(PushAll.Factory pushFactory, ReplicationStateLogger stateLog) { + this.pushFactory = pushFactory; + this.stateLog = stateLog; + } + + void start( + @Nullable String urlMatch, + Set<String> remotesToConsider, + ReplicationFilter filter, + boolean now, + boolean wait, + SshOutputCommand sink) { + ReplicationState state = new ReplicationState(new CommandProcessing(sink)); + + Future<?> future = + pushFactory + .create(urlMatch, remotesToConsider, filter, state, now) + .schedule(0, TimeUnit.SECONDS); + + if (wait) { + if (future != null) { + try { + future.get(); + } catch (InterruptedException e) { + stateLog.error( + "Thread was interrupted while waiting for PushAll operation to finish", e, state); + return; + } catch (ExecutionException e) { + stateLog.error("An exception was thrown in PushAll operation", e, state); + return; + } + } + + if (state.hasPushTask()) { + try { + state.waitForReplication(); + } catch (InterruptedException e) { + sink.writeStdErrSync("We are interrupted while waiting replication to complete"); + } + } else { + sink.writeStdOutSync("Nothing to replicate"); + } + } + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java index a66cce6..78410f8 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/SshModule.java
@@ -27,6 +27,7 @@ @Override protected void configureCommands() { command(StartCommand.class); + command(RepairCommand.class); command(ListCommand.class); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java index 2458a26..6d59283 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/StartCommand.java
@@ -18,14 +18,10 @@ import com.google.gerrit.sshd.CommandMetaData; import com.google.gerrit.sshd.SshCommand; import com.google.inject.Inject; -import com.googlesource.gerrit.plugins.replication.PushResultProcessing.CommandProcessing; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.Option; @@ -33,9 +29,7 @@ @CommandMetaData( name = "start", description = "Start replication for specific project or all projects") -final class StartCommand extends SshCommand { - @Inject private ReplicationStateLogger stateLog; - +final class StartCommand extends SshCommand implements PushResultProcessing.SshOutputCommand { @Option(name = "--all", usage = "push all known projects") private boolean all; @@ -58,7 +52,7 @@ @Argument(index = 0, multiValued = true, metaVar = "PATTERN", usage = "project name pattern") private List<String> projectPatterns = new ArrayList<>(2); - @Inject private PushAll.Factory pushFactory; + @Inject private ReplicationStarter replicationStarter; private final Object lock = new Object(); @@ -68,47 +62,13 @@ throw new UnloggedFailure(1, "error: cannot combine --all and PROJECT"); } - ReplicationState state = new ReplicationState(new CommandProcessing(this)); + ReplicationFilter projectFilter = + all ? ReplicationFilter.all() : new ReplicationFilter(projectPatterns); - ReplicationFilter projectFilter; - - if (all) { - projectFilter = ReplicationFilter.all(); - } else { - projectFilter = new ReplicationFilter(projectPatterns); - } - - Future<?> future = - pushFactory - .create(urlMatch, remotesToConsider, projectFilter, state, now) - .schedule(0, TimeUnit.SECONDS); - - if (wait) { - if (future != null) { - try { - future.get(); - } catch (InterruptedException e) { - stateLog.error( - "Thread was interrupted while waiting for PushAll operation to finish", e, state); - return; - } catch (ExecutionException e) { - stateLog.error("An exception was thrown in PushAll operation", e, state); - return; - } - } - - if (state.hasPushTask()) { - try { - state.waitForReplication(); - } catch (InterruptedException e) { - writeStdErrSync("We are interrupted while waiting replication to complete"); - } - } else { - writeStdOutSync("Nothing to replicate"); - } - } + replicationStarter.start(urlMatch, remotesToConsider, projectFilter, now, wait, this); } + @Override public void writeStdOutSync(String message) { if (wait) { synchronized (lock) { @@ -118,6 +78,7 @@ } } + @Override public void writeStdErrSync(String message) { if (wait) { synchronized (lock) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java index d5ccb02..1714c1a 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
@@ -89,6 +89,14 @@ int getSshCommandTimeout(); /** + * Path of the {@code rsync} binary on the host running Gerrit, used by the {@code replication + * repair} command. + * + * @return the rsync binary path, or {@code "rsync"} to resolve via {@code PATH}. + */ + String getRsyncPath(); + + /** * Current logical version string of the current configuration loaded in memory, depending on the * actual implementation of the configuration on the persistent storage. *
diff --git a/src/main/resources/Documentation/cmd-repair.md b/src/main/resources/Documentation/cmd-repair.md new file mode 100644 index 0000000..3f95907 --- /dev/null +++ b/src/main/resources/Documentation/cmd-repair.md
@@ -0,0 +1,100 @@ +@PLUGIN@ repair +=============== + +NAME +---- +@PLUGIN@ repair - Repair a project on replication destinations + +SYNOPSIS +-------- + +```console +ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair + [--url <PATTERN>] + [--full | --copy-packs] + <PROJECT> +``` + +DESCRIPTION +----------- +Repairs a project on its replication destinations, then runs a +`@PLUGIN@ start` for that project (with `--now --wait`) so +any refs that diverged during the repair are replicated. The command +blocks until replication finishes. + +If no repair action flag is supplied, `--full` is assumed. + +REQUIREMENTS +------------ +The Gerrit runtime user must have `ssh` on `PATH`, plus `rsync` either on +`PATH` or pointed at via [`replication.rsyncPath`](config.md#replication.rsyncPath). + +ACCESS +------ +Caller must be a member of the privileged 'Administrators' group, +or have been granted the 'Start Replication' plugin-owned capability. + +SCRIPTING +--------- +This command is intended to be used to repair repositories on the mirror. +Exit status is non-zero if the project is missing, or if the repair fails +for some reason. + +OPTIONS +------- + +`--url <PATTERN>` +: Restrict both the repair action(s) and the follow-up replication to +replication destinations whose configuration URL contains the substring +`PATTERN`, or whose expanded project URL contains `PATTERN`. + +`--full` +: Run every supported repair action. + +`--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. + +`PROJECT` +: Exact Gerrit project name. + +EXAMPLES +-------- +Run every supported repair action for `tools/gerrit` against every +eligible destination, then replicate refs (`--full` is implied since no +action flag is given): + +```console + $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair tools/gerrit +``` + +Equivalent, with `--full` stated explicitly: + +```console + $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair --full tools/gerrit +``` + +Only copy packs (no other repair actions, even if more are added later): + +```console + $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair --copy-packs tools/gerrit +``` + +Repair only against destinations whose URL mentions `replica1`: + +```console + $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ repair --url replica1 tools/gerrit +``` + +SEE ALSO +-------- + +* [@PLUGIN@ start](cmd-start.md) +* [Replication Configuration](config.md) +* [Access Control](../../../Documentation/access-control.html)
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index a575f03..4ec1124 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -261,6 +261,14 @@ When not set, defaults to the plugin's data directory. +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. + + Default: `rsync` (resolved via the Gerrit runtime user's `PATH`) + remote.NAME.url : Address of the remote server to push to. Multiple URLs may be specified within a single remote block, listing different