Support configurable git binary path for SSH remotes

Some SSH destinations may not expose 'git' on the non-interactive
shell's PATH, causing 'create project' and 'update head' operations
to fail. Add a new 'remote.NAME.gitPath' config option that lets
admins provide the absolute path to the 'git' binary on the remote
host.

Change-Id: I01e32cff5240cb55cf9e0fc0539344b340361ac4
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AdminApiFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AdminApiFactory.java
index 30e8245..25e9d18 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AdminApiFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AdminApiFactory.java
@@ -14,41 +14,54 @@
 
 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.api.ReplicationConfig;
 import java.util.Optional;
 import org.eclipse.jgit.transport.URIish;
 
 /** Factory for creating an {@link AdminApi} instance for a remote URI. */
 public interface AdminApiFactory {
   /**
-   * Create an {@link AdminApi} for the given remote URI.
+   * Create an {@link AdminApi} for the given remote URI with knowledge of which {@code
+   * remote.<remoteName>} section in {@code replication.config} the URI belongs to.
    *
    * @param uri the remote URI.
+   * @param remoteName the name of the {@code remote} section, or {@code null} when unknown.
    * @return An API for the given remote URI, or {@code Optional.empty} if there is no appropriate
    *     API for the URI.
    */
-  Optional<AdminApi> create(URIish uri);
+  Optional<AdminApi> create(URIish uri, @Nullable String remoteName);
 
   @Singleton
   static class DefaultAdminApiFactory implements AdminApiFactory {
     protected final SshHelper sshHelper;
     private final GerritRestApi.Factory gerritRestApiFactory;
+    private final ReplicationConfig replicationConfig;
 
     @Inject
-    public DefaultAdminApiFactory(SshHelper sshHelper, GerritRestApi.Factory gerritRestApiFactory) {
+    public DefaultAdminApiFactory(
+        SshHelper sshHelper,
+        GerritRestApi.Factory gerritRestApiFactory,
+        ReplicationConfig replicationConfig) {
       this.sshHelper = sshHelper;
       this.gerritRestApiFactory = gerritRestApiFactory;
+      this.replicationConfig = replicationConfig;
     }
 
     @Override
-    public Optional<AdminApi> create(URIish uri) {
+    public Optional<AdminApi> create(URIish uri, @Nullable String remoteName) {
       if (isGerrit(uri)) {
         return Optional.of(new GerritSshApi(sshHelper, uri));
       } else if (!uri.isRemote()) {
         return Optional.of(new LocalFS(uri));
       } else if (isSSH(uri)) {
-        return Optional.of(new RemoteSsh(sshHelper, uri));
+        String gitPath =
+            remoteName == null
+                ? null
+                : replicationConfig.getConfig().getString("remote", remoteName, "gitPath");
+        return Optional.of(new RemoteSsh(sshHelper, uri, gitPath));
       } else if (isGerritHttp(uri)) {
         return Optional.of(gerritRestApiFactory.create(uri));
       }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java
index 32903ab..04f9869 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java
@@ -61,7 +61,7 @@
 
   private boolean createProject(
       URIish replicateURI, Project.NameKey projectName, String head, boolean storeRefLog) {
-    Optional<AdminApi> adminApi = adminApiFactory.get().create(replicateURI);
+    Optional<AdminApi> adminApi = adminApiFactory.get().create(replicateURI, config.getName());
     if (adminApi.isPresent() && adminApi.get().createProject(projectName, head, storeRefLog)) {
       return true;
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java
index 965ca94..f3fcafe 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java
@@ -24,6 +24,7 @@
 import com.google.inject.assistedinject.Assisted;
 import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionState;
 import java.util.Optional;
+import org.eclipse.jgit.transport.RemoteConfig;
 import org.eclipse.jgit.transport.URIish;
 
 public class DeleteProjectTask implements Runnable {
@@ -33,6 +34,7 @@
         URIish replicateURI, Project.NameKey project, ProjectDeletionState state);
   }
 
+  private final RemoteConfig config;
   private final DynamicItem<AdminApiFactory> adminApiFactory;
   private final int id;
   private final URIish replicateURI;
@@ -41,11 +43,13 @@
 
   @Inject
   DeleteProjectTask(
+      RemoteConfig config,
       DynamicItem<AdminApiFactory> adminApiFactory,
       IdGenerator ig,
       @Assisted ProjectDeletionState state,
       @Assisted URIish replicateURI,
       @Assisted Project.NameKey project) {
+    this.config = config;
     this.adminApiFactory = adminApiFactory;
     this.id = ig.next();
     this.replicateURI = replicateURI;
@@ -55,7 +59,7 @@
 
   @Override
   public void run() {
-    Optional<AdminApi> adminApi = adminApiFactory.get().create(replicateURI);
+    Optional<AdminApi> adminApi = adminApiFactory.get().create(replicateURI, config.getName());
     if (adminApi.isPresent()) {
       if (adminApi.get().deleteProject(project)) {
         state.setSucceeded(replicateURI);
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 f0b26f9..9c1bfba 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -898,6 +898,18 @@
     return config.storeRefLog();
   }
 
+  String getUploadPack() {
+    return config.getUploadPack();
+  }
+
+  String getReceivePack() {
+    return config.getReceivePack();
+  }
+
+  String getGitPath() {
+    return config.getGitPath();
+  }
+
   private 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/DestinationConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
index b9e1be9..d2b9686 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfiguration.java
@@ -57,6 +57,9 @@
   private final Supplier<Integer> pushBatchSize;
   private final ImmutableList<Pattern> excludedRefsPattern;
   private final boolean storeRefLog;
+  private final String uploadPack;
+  private final String receivePack;
+  private final String gitPath;
 
   protected DestinationConfiguration(RemoteConfig remoteConfig, Config cfg) {
     this.remoteConfig = remoteConfig;
@@ -124,6 +127,9 @@
             });
     excludedRefsPattern = getExcludedRefsPattern(cfg, name);
     storeRefLog = cfg.getBoolean("remote", name, "storeRefLog", false);
+    uploadPack = cfg.getString("remote", name, "uploadpack");
+    receivePack = cfg.getString("remote", name, "receivepack");
+    gitPath = cfg.getString("remote", name, "gitPath");
   }
 
   @Override
@@ -234,6 +240,18 @@
     return storeRefLog;
   }
 
+  public String getUploadPack() {
+    return uploadPack;
+  }
+
+  public String getReceivePack() {
+    return receivePack;
+  }
+
+  public String getGitPath() {
+    return gitPath;
+  }
+
   private ImmutableList<Pattern> getExcludedRefsPattern(Config cfg, String name) {
     List<Pattern> patterns = new ArrayList<>();
     for (String regex : cfg.getStringList("remote", name, "excludedRefsPattern")) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
index 030ba1c..0e04e1e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -24,6 +24,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.LinkedListMultimap;
@@ -558,6 +559,7 @@
   private PushResult pushVia(Repository git, Transport tn)
       throws IOException, PermissionBackendException {
     tn.applyConfig(config);
+    setUploadAndReceivePack(tn);
     tn.setCredentialsProvider(credentialsFactory.create(config.getName()));
 
     List<RemoteRefUpdate> todo = generateUpdates(git, tn);
@@ -612,6 +614,24 @@
     return result;
   }
 
+  private void setUploadAndReceivePack(Transport tn) {
+    String gitPath = pool.getGitPath();
+    if (Strings.isNullOrEmpty(gitPath)) {
+      return;
+    }
+    int lastSlash = gitPath.lastIndexOf('/');
+    if (lastSlash < 0) {
+      return;
+    }
+    String binDir = gitPath.substring(0, lastSlash + 1);
+    if (pool.getUploadPack() == null) {
+      tn.setOptionUploadPack(binDir + "git-upload-pack");
+    }
+    if (pool.getReceivePack() == null) {
+      tn.setOptionReceivePack(binDir + "git-receive-pack");
+    }
+  }
+
   private static String refUpdatesForLogging(List<RemoteRefUpdate> refUpdates) {
     return refUpdates.stream().map(PushOne::refUpdateForLogging).collect(joining(", "));
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteSsh.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteSsh.java
index f96c157..2c9cb03 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteSsh.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteSsh.java
@@ -16,6 +16,8 @@
 
 import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
 
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -26,18 +28,24 @@
 
   private final SshHelper sshHelper;
   private URIish uri;
+  private final String git;
 
   RemoteSsh(SshHelper sshHelper, URIish uri) {
+    this(sshHelper, uri, null);
+  }
+
+  RemoteSsh(SshHelper sshHelper, URIish uri, @Nullable String gitPath) {
     this.sshHelper = sshHelper;
     this.uri = uri;
+    this.git = Strings.isNullOrEmpty(gitPath) ? "git" : QuotedString.BOURNE.quote(gitPath);
   }
 
   @Override
   public boolean createProject(Project.NameKey project, String head) {
     String quotedPath = QuotedString.BOURNE.quote(uri.getPath());
-    String cmd = "mkdir -p " + quotedPath + " && cd " + quotedPath + " && git init --bare";
+    String cmd = "mkdir -p " + quotedPath + " && cd " + quotedPath + " && " + git + " init --bare";
     if (head != null) {
-      cmd = cmd + " && git symbolic-ref HEAD " + QuotedString.BOURNE.quote(head);
+      cmd = cmd + " && " + git + " symbolic-ref HEAD " + QuotedString.BOURNE.quote(head);
     }
     OutputStream errStream = sshHelper.newErrorBufferStream();
     try {
@@ -79,7 +87,12 @@
   public boolean updateHead(Project.NameKey project, String newHead) {
     String quotedPath = QuotedString.BOURNE.quote(uri.getPath());
     String cmd =
-        "cd " + quotedPath + " && git symbolic-ref HEAD " + QuotedString.BOURNE.quote(newHead);
+        "cd "
+            + quotedPath
+            + " && "
+            + git
+            + " symbolic-ref HEAD "
+            + QuotedString.BOURNE.quote(newHead);
     OutputStream errStream = sshHelper.newErrorBufferStream();
     try {
       sshHelper.executeRemoteSsh(uri, cmd, errStream);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/UpdateHeadTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/UpdateHeadTask.java
index fdbd5e7..ce6370c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/UpdateHeadTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/UpdateHeadTask.java
@@ -23,10 +23,12 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Optional;
+import org.eclipse.jgit.transport.RemoteConfig;
 import org.eclipse.jgit.transport.URIish;
 
 public class UpdateHeadTask implements Runnable {
   private final DynamicItem<AdminApiFactory> adminApiFactory;
+  private final RemoteConfig remoteConfig;
   private final int id;
   private final URIish replicateURI;
   private final Project.NameKey project;
@@ -39,11 +41,13 @@
   @Inject
   UpdateHeadTask(
       DynamicItem<AdminApiFactory> adminApiFactory,
+      RemoteConfig remoteConfig,
       IdGenerator ig,
       @Assisted URIish replicateURI,
       @Assisted Project.NameKey project,
       @Assisted String newHead) {
     this.adminApiFactory = adminApiFactory;
+    this.remoteConfig = remoteConfig;
     this.id = ig.next();
     this.replicateURI = replicateURI;
     this.project = project;
@@ -52,7 +56,8 @@
 
   @Override
   public void run() {
-    Optional<AdminApi> adminApi = adminApiFactory.get().create(replicateURI);
+    Optional<AdminApi> adminApi =
+        adminApiFactory.get().create(replicateURI, remoteConfig.getName());
     if (adminApi.isPresent()) {
       adminApi.get().updateHead(project, newHead);
       return;
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 150810e..a575f03 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -342,7 +342,9 @@
 :	Path of the `git-receive-pack` executable on the remote
 	system, if using the SSH transport.
 
-	Defaults to `git-receive-pack`.
+	If not set and `remote.NAME.gitPath` is configured, defaults to
+	`git-receive-pack` in the directory of `gitPath`. Otherwise defaults
+	to `git-receive-pack`.
 
 remote.NAME.storeRefLog
 :	`true` if the remote repositories should be enabled for storing
@@ -359,7 +361,9 @@
 :	Path of the `git-upload-pack` executable on the remote system,
 	if using the SSH transport.
 
-	Defaults to `git-upload-pack`.
+	If not set and `remote.NAME.gitPath` is configured, defaults to
+	`git-upload-pack` in the directory of `gitPath`. Otherwise defaults
+	to `git-upload-pack`.
 
 remote.NAME.push
 :	Standard Git refspec denoting what should be replicated.
@@ -500,6 +504,21 @@
 
 	By default, true, missing repositories are created.
 
+remote.NAME.gitPath
+:	Absolute path to the `git` binary on this SSH destination, used when
+	creating a missing repository or updating its HEAD. Set this when the
+	non-interactive SSH session on the remote host does not have `git`
+	in its `PATH`.
+
+	When set, the directory of `gitPath` is also used as the default
+	location for `git-upload-pack` and `git-receive-pack`, unless
+	`remote.NAME.uploadpack` or `remote.NAME.receivepack` are configured
+	explicitly.
+
+	Only applies to SSH destinations.
+
+	Default: `git` (resolved via the remote's `PATH`)
+
 remote.NAME.replicatePermissions
 :	If true, permissions-only projects and the refs/meta/config
 	branch will also be replicated to the remote site.  These