Repository: vackosar/gitflow-incremental-builder
Pull request #112 (2020-01-18 16:11:10)

Issue #106: support fetch from remote with basic auth


Patch:
diff --git a/README.md b/README.md
index d5d6a4a..8d78490 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,10 @@ This extension is **not limited to Git Flow setups!** The [extensive configurati
   - [gib.help](#gibhelp)
   - [gib.enabled](#gibenabled)
   - [gib.disableBranchComparison](#gibdisablebranchcomparison)
+  - [gib.referenceBranch](#gibreferencebranch)
+  - [gib.fetchReferenceBranch](#gibfetchreferencebranch)
+  - [gib.baseBranch](#gibbasebranch)
+  - [gib.fetchBaseBranch](#gibfetchbasebranch)
   - [gib.uncommited](#gibuncommited)
   - [gib.untracked](#gibuntracked)
   - [gib.excludePathRegex](#gibexcludePathRegex)
@@ -297,7 +301,33 @@ The following properties are _not_ evaluated when `gib.disableBranchComparison`
 - `gib.referenceBranch`
 - `gib.compareToMergeBase`
 - `gib.fetchReferenceBranch`
-- `gib.excludePathRegex`
+
+### gib.referenceBranch
+
+The branch to compare `baseBranch` to.
+
+### gib.fetchReferenceBranch
+
+Fetches the `referenceBranch` from the remote repository.
+
+In case the remote repo requires **authentication**, GIB will query the credentials from the local native Git executable via [`git credential fill`](https://git-scm.com/docs/git-credential).<br/>
+These credentials are then forwarded to JGit and are not persisted in any way. GIB will only cache the credentials _transiently_ for a very short time and will actively remove them as soon as possible.<br/>
+See also [DelegatingCredentialsProvider in DifferentFiles.java](../blob/master/src/main/java/com/vackosar/gitflowincrementalbuild/control/DifferentFiles.java).
+
+Since `git credential fill` will trigger all configured [credential helpers](https://git-scm.com/docs/gitcredentials) (if any), you _might_ see a popup dialog box asking for credentials.<br/>
+This only happens in case the respective helper was _not_ able to provide the credentials. Such a dialog box is _not_ created by GIB, instead it is spawned by a configured credential helper!
+
+As GIB does _not_ (yet) provide a console input passthrough mechanism to native Git, console input queries by native Git are disabled. This means that if _no_ credential helper is configured, GIB will _not_ be able to fetch from a remote repo that requires authentication.
+
+### gib.baseBranch
+
+The branch that is compared to `referenceBranch`. Usually just the current `HEAD`.
+
+### gib.fetchBaseBranch
+
+Fetches the `baseBranch` from the remote repository.
+
+See [gib.fetchReferenceBranch](#gibfetchreferencebranch) for **authentication** details.
 
 ### gib.uncommited
 
diff --git a/pom.xml b/pom.xml
index 161eb47..27cc371 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
     <modelVersion>4.0.0</modelVersion>
     <groupId>com.vackosar.gitflowincrementalbuilder</groupId>
     <artifactId>gitflow-incremental-builder</artifactId>
-    <version>3.8.2-SNAPSHOT</version>
+    <version>3.9.0-SNAPSHOT</version>
     <name>${project.groupId}:${project.artifactId}</name>
     <description>A maven extension for incremental building of multi-module projects when using Git Flow.</description>
     <url>https://github.com/vackosar/gitflow-incremental-builder</url>
@@ -65,6 +65,7 @@
         <version.junit>4.12</version.junit>
         <version.powermock-reflect>1.7.4</version.powermock-reflect>
         <version.mockito-all>1.10.19</version.mockito-all>
+        <version.jetty>9.4.25.v20191220</version.jetty>
 
         <!-- Plugin versions -->
         <version.maven-enforcer-plugin>3.0.0-M3</version.maven-enforcer-plugin>
@@ -162,6 +163,24 @@
             <version>${version.logback}</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.eclipse.jgit</groupId>
+            <artifactId>org.eclipse.jgit.http.server</artifactId>
+            <version>${version.jgit}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-server</artifactId>
+            <version>${version.jetty}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jetty</groupId>
+            <artifactId>jetty-servlet</artifactId>
+            <version>${version.jetty}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
     <build>
         <defaultGoal>clean install</defaultGoal>
diff --git a/src/main/java/com/vackosar/gitflowincrementalbuild/control/DifferentFiles.java b/src/main/java/com/vackosar/gitflowincrementalbuild/control/DifferentFiles.java
index f955e93..4da6131 100644
--- a/src/main/java/com/vackosar/gitflowincrementalbuild/control/DifferentFiles.java
+++ b/src/main/java/com/vackosar/gitflowincrementalbuild/control/DifferentFiles.java
@@ -7,14 +7,21 @@ import org.apache.maven.execution.MavenSession;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.Status;
 import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.errors.UnsupportedCredentialItem;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.revwalk.filter.RevFilter;
 import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.URIish;
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.treewalk.filter.TreeFilter;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.FS.ExecutionResult;
+import org.eclipse.jgit.util.TemporaryBuffer;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -22,12 +29,20 @@ import javax.inject.Inject;
 import javax.inject.Named;
 import javax.inject.Singleton;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 @Singleton
@@ -45,13 +60,16 @@ public class DifferentFiles {
     @Inject private MavenSession mavenSession;
     @Inject private Configuration.Provider configProvider;
 
+    private final Map<String, String> additionalNativeGitEnvironment = new HashMap<>();
+
     public Set<Path> get() throws GitAPIException, IOException {
         Set<Path> paths = new HashSet<>();
 
         Configuration configuration = configProvider.get();
         Git git = setupGit(configuration);
+        Worker worker = null;
         try {
-            Worker worker = new Worker(git, configuration);
+            worker = new Worker(git, configuration);
 
             worker.fetch();
             worker.checkout();
@@ -62,12 +80,25 @@ public class DifferentFiles {
                 paths.addAll(worker.getChangesFromStatus());
             }
         } finally {
+            if (worker != null) {
+                worker.credentialsProvider.resetAll();
+            }
             git.getRepository().close();
             git.close();
         }
         return paths;
     }
 
+    /**
+     * Only for testing!
+     *
+     * @param key environment key for {@link DelegatingCredentialsProvider#lookupCredentials(URIish)}
+     * @param value environment value for {@link DelegatingCredentialsProvider#lookupCredentials(URIish)}
+     */
+    void putAdditionalNativeGitEnvironment(String key, String value) {
+        additionalNativeGitEnvironment.put(key, value);
+    }
+
     private Git setupGit(Configuration configuration) throws IOException {
         final FileRepositoryBuilder builder = new FileRepositoryBuilder();
         File pomDir = mavenSession.getCurrentProject().getBasedir().toPath().toFile();
@@ -98,11 +129,13 @@ public class DifferentFiles {
         private final Git git;
         private final Path workTree;
         private final Configuration configuration;
+        private final DelegatingCredentialsProvider credentialsProvider;
 
         public Worker(Git git, Configuration configuration) {
             this.git = git;
             this.workTree = git.getRepository().getWorkTree().toPath().normalize().toAbsolutePath();
             this.configuration = configuration;
+            this.credentialsProvider = new DelegatingCredentialsProvider(workTree);
         }
 
         private Set<Path> getBranchDiff() throws IOException {
@@ -142,7 +175,11 @@ public class DifferentFiles {
             }
             String remoteName = extractRemoteName(branchName);
             String shortName = extractShortName(remoteName, branchName);
-            git.fetch().setRemote(remoteName).setRefSpecs(new RefSpec(REFS_HEADS + shortName + ":" + branchName)).call();
+            git.fetch()
+                    .setCredentialsProvider(credentialsProvider)
+                    .setRemote(remoteName)
+                    .setRefSpecs(new RefSpec(REFS_HEADS + shortName + ":" + branchName))
+                    .call();
         }
 
         private String extractRemoteName(String branchName) {
@@ -220,4 +257,155 @@ public class DifferentFiles {
             return !excluded;
         }
     }
+
+    /**
+     * JGit-{@link CredentialsProvider} that is delegating all credential requests to native Git via {@code git credential fill}. This will consult all
+     * configured credential helpers, if any (for the repo, the user and the system). Such a helper might query the user for the credentials in case it cannot
+     * yet provide them. However, the assumption here is that the credentials should already exist. Therefore this provider does <i>not</i> give feedback to
+     * native Git via {@code git credential approve} or {@code git credential verify}.<p/>
+     * This provider will suppress any console input requests (see
+     * <a href="https://git-scm.com/docs/git#Documentation/git.txt-codeGITTERMINALPROMPTcode">GIT_TERMINAL_PROMPT</a>).
+     *
+     * @see <a href="https://git-scm.com/docs/git-credential">Git documentation: git credential</a>
+     */
+    private class DelegatingCredentialsProvider extends CredentialsProvider {
+
+        private Logger logger = LoggerFactory.getLogger(DelegatingCredentialsProvider.class);
+
+        private final Path projectDir;
+
+        private final Map<URIish, CredentialsPair> credentials = new HashMap<>();
+
+        public DelegatingCredentialsProvider(Path projectDir) {
+            this.projectDir = projectDir;
+        }
+
+        @Override
+        public boolean isInteractive() {
+            // possibly interactive in case some credential helper asks for input
+            return true;
+        }
+
+        @Override
+        public boolean supports(CredentialItem... items) {
+            return Arrays.stream(items)
+                    .allMatch(item -> item instanceof CredentialItem.Username || item instanceof CredentialItem.Password);
+        }
+
+        @Override
+        public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem {
+
+            CredentialsPair credentialsPair = credentials.computeIfAbsent(uri, u -> {
+                try {
+                    return lookupCredentials(uri);
+                } catch (IOException | InterruptedException | RuntimeException e) {
+                    logger.warn("Failed to look up credentials via 'git credential fill' for: " + uri, e);
+                    return null;
+                }
+            });
+            if (credentialsPair == null) {
+                return false;
+            }
+
+            // map extracted credentials to CredentialItems, see also: org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider
+            for (CredentialItem item : items) {
+                if (item instanceof CredentialItem.Username) {
+                    ((CredentialItem.Username) item).setValue(credentialsPair.username);
+                } else if (item instanceof CredentialItem.Password) {
+                    ((CredentialItem.Password) item).setValue(credentialsPair.password);
+                } else if (item instanceof CredentialItem.StringType && item.getPromptText().equals("Password: ")) {
+                    ((CredentialItem.StringType) item).setValue(new String(credentialsPair.password));
+                } else {
+                    throw new UnsupportedCredentialItem(uri, item.getClass().getName() + ":" + item.getPromptText());
+                }
+            }
+
+            return true;
+        }
+
+        @Override
+        // see also: org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider.clear()
+        public void reset(URIish uri) {
+            Optional.ofNullable(credentials.remove(uri))
+                    .ifPresent(credPair -> {
+                        credPair.username = null;
+                        Arrays.fill(credPair.password, (char) 0);
+                        credPair.password = null;
+                    });
+        }
+
+        public void resetAll() {
+            new HashSet<>(credentials.keySet()).forEach(this::reset);
+        }
+
+        private CredentialsPair lookupCredentials(URIish uri) throws IOException, InterruptedException {
+            // utilize JGit command execution capabilities
+            FS fs = FS.detect();
+            ProcessBuilder procBuilder = fs.runInShell("git", new String[] {"credential", "fill"});
+
+            // prevent native git from requesting console input (not implemented)
+            procBuilder.environment().put("GIT_TERMINAL_PROMPT", "0");
+
+            // add additional environment entries, if present (test only)
+            if (!additionalNativeGitEnvironment.isEmpty()) {
+                procBuilder.environment().putAll(additionalNativeGitEnvironment);
+            }
+            procBuilder.directory(projectDir.toFile());
+
+            ExecutionResult result = fs.execute(procBuilder, new ByteArrayInputStream(buildGitCommandInput(uri).getBytes()));
+            if (result.getRc() != 0) {
+                logger.info(bufferToString(result.getStdout()));
+                logger.error(bufferToString(result.getStderr()));
+                throw new IllegalStateException("Native Git invocation failed with return code " + result.getRc()
+                        + ". See previous log output for more details.");
+            }
+
+            return extractCredentials(bufferToString(result.getStdout()));
+        }
+
+        // build input for "git credential fill" as per https://git-scm.com/docs/git-credential#_typical_use_of_git_credential
+        private String buildGitCommandInput(URIish uri) {
+            StringBuilder builder = new StringBuilder();
+            builder.append("protocol=").append(uri.getScheme()).append("\n");
+            builder.append("host=").append(uri.getHost());
+            if (uri.getPort() != -1) {
+                builder.append(":").append(uri.getPort());
+            }
+            builder.append("\n");
+            Optional.ofNullable(uri.getPath())
+                    .map(path -> path.startsWith("/") ? path.substring(1) : path)
+                    .ifPresent(path -> builder.append("path=").append(path).append("\n"));
+            Optional.ofNullable(uri.getUser())
+                    .ifPresent(user -> builder.append("username=").append(user).append("\n"));
+            return builder.toString();
+        }
+
+        private String bufferToString(TemporaryBuffer buffer) throws IOException {
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            buffer.writeTo(baos, null);
+            return baos.toString();
+        }
+
+        private CredentialsPair extractCredentials(String nativeGitOutput) {
+            Matcher matcher = Pattern.compile("(?<=username=).+|(?<=password=).+").matcher(nativeGitOutput);
+            if (!matcher.find()) {
+                throw new IllegalStateException("Could not find username in native Git output");
+            }
+            String username = matcher.group();
+            if (!matcher.find()) {
+                throw new IllegalStateException("Could not find password in native Git output");
+            }
+            char[] password = matcher.group().toCharArray();
+
+            CredentialsPair credPair = new CredentialsPair();
+            credPair.username = username;
+            credPair.password = password;
+            return credPair;
+        }
+
+        private class CredentialsPair {
+            private String username;
+            private char[] password;
+        }
+    }
 }


Test patch:
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/BaseRepoTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/BaseRepoTest.java
index d085309..1ef7092 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/BaseRepoTest.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/BaseRepoTest.java
@@ -3,6 +3,7 @@ package com.vackosar.gitflowincrementalbuild;
 import com.vackosar.gitflowincrementalbuild.control.Property;
 import com.vackosar.gitflowincrementalbuild.mocks.LocalRepoMock;
 import com.vackosar.gitflowincrementalbuild.mocks.MavenSessionMock;
+import com.vackosar.gitflowincrementalbuild.mocks.server.TestServerType;
 
 import org.apache.commons.lang3.SystemUtils;
 import org.apache.maven.execution.MavenSession;
@@ -21,7 +22,7 @@ public abstract class BaseRepoTest {
     /** The project properties for the top-level project of {@link #getMavenSessionMock() MavenSessionMock} for config init. */
     protected final Properties projectProperties = new Properties();
     private final boolean useSymLinkedFolder;
-    private final boolean withRemote;
+    private final TestServerType remoteRepoServerType;
 
     protected LocalRepoMock localRepoMock;
     /** {@link LocalRepoMock#getBaseCanonicalBaseFolder()} of {@link #localRepoMock}. */
@@ -31,12 +32,12 @@ public abstract class BaseRepoTest {
     public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
     public BaseRepoTest() {
-        this(false, false);
+        this(false, null);
     }
 
-    public BaseRepoTest(boolean useSymLinkedFolder, boolean withRemote) {
+    public BaseRepoTest(boolean useSymLinkedFolder, TestServerType remoteRepoServerType) {
         this.useSymLinkedFolder = useSymLinkedFolder;
-        this.withRemote = withRemote;
+        this.remoteRepoServerType = remoteRepoServerType;
     }
 
     @Before
@@ -64,7 +65,7 @@ public abstract class BaseRepoTest {
 
             repoBaseFolder = link.toFile();
         }
-        localRepoMock = new LocalRepoMock(repoBaseFolder, withRemote);
+        localRepoMock = new LocalRepoMock(repoBaseFolder, remoteRepoServerType);
         repoPath = localRepoMock.getBaseCanonicalBaseFolder().toPath();
     }
 
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/control/BaseChangedProjectsTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/control/BaseChangedProjectsTest.java
index 9531405..6dfde05 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/control/BaseChangedProjectsTest.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/control/BaseChangedProjectsTest.java
@@ -46,7 +46,7 @@ public abstract class BaseChangedProjectsTest extends BaseRepoTest {
     private MavenSession mavenSessionMock;
 
     public BaseChangedProjectsTest(boolean useSymLinkedFolder) {
-        super(useSymLinkedFolder, /* withRemote */ false);
+        super(useSymLinkedFolder, /* remoteRepoServerType */ null);
     }
 
     @Before
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/control/BaseDifferentFilesTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/control/BaseDifferentFilesTest.java
new file mode 100644
index 0000000..3007a03
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/control/BaseDifferentFilesTest.java
@@ -0,0 +1,73 @@
+package com.vackosar.gitflowincrementalbuild.control;
+
+import java.nio.file.Path;
+import java.util.Set;
+
+import org.apache.maven.execution.MavenSession;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.ResetCommand;
+import org.junit.Assert;
+import org.junit.Before;
+import org.powermock.reflect.Whitebox;
+import org.slf4j.Logger;
+
+import com.vackosar.gitflowincrementalbuild.BaseRepoTest;
+import com.vackosar.gitflowincrementalbuild.LoggerSpyUtil;
+import com.vackosar.gitflowincrementalbuild.boundary.Configuration;
+import com.vackosar.gitflowincrementalbuild.mocks.server.TestServerType;
+
+public abstract class BaseDifferentFilesTest extends BaseRepoTest {
+
+    protected static final String FETCH_FILE = "fetch-file";
+    protected static final String DEVELOP = "refs/heads/develop";
+    protected static final String REMOTE_DEVELOP = "refs/remotes/origin/develop";
+
+    protected final Logger loggerSpy = LoggerSpyUtil.buildSpiedLoggerFor(DifferentFiles.class);
+
+    protected Path nativeGitUserHome;
+
+    public BaseDifferentFilesTest(TestServerType remoteRepoServerType) {
+        super(false, remoteRepoServerType);
+    }
+
+    @Override
+    @Before
+    public void before() throws Exception {
+        super.before();
+        nativeGitUserHome = temporaryFolder.newFolder("nativeGitUserHome").toPath();
+    }
+
+    protected void addCommitToRemoteRepo(String newFileNameAndMessage) throws Exception {
+        Git remoteGit = localRepoMock.getRemoteRepo().getGit();
+        remoteGit.reset().setMode(ResetCommand.ResetType.HARD).call();
+        remoteGit.checkout().setName(DEVELOP).call();
+        remoteGit.getRepository().getDirectory().toPath().resolve(newFileNameAndMessage).toFile().createNewFile();
+        remoteGit.add().addFilepattern(".").call();
+        remoteGit.commit().setMessage(newFileNameAndMessage).call();
+        assertCommitExists(newFileNameAndMessage, remoteGit);
+    }
+
+    protected void assertCommitExists(String message, Git git) throws Exception {
+        Assert.assertEquals(message, git.log().setMaxCount(1).call().iterator().next().getFullMessage());
+    }
+
+    protected Set<Path> invokeUnderTest() throws Exception {
+        return invokeUnderTest(getMavenSessionMock());
+    }
+
+    protected Set<Path> invokeUnderTest(final MavenSession mavenSessionMock) throws Exception {
+        mavenSessionMock.getTopLevelProject().getProperties().putAll(projectProperties);
+
+        DifferentFiles underTest = new DifferentFiles();
+        Whitebox.setInternalState(underTest, mavenSessionMock, new Configuration.Provider(mavenSessionMock), loggerSpy);
+
+        // isolate a possible native git invocation from the settings of the system the test is runing on
+        underTest.putAdditionalNativeGitEnvironment("GIT_CONFIG_NOSYSTEM", "1");
+        underTest.putAdditionalNativeGitEnvironment("HOME", nativeGitUserHome.toAbsolutePath().toString());
+
+        Set<Path> result = underTest.get();
+
+        Assert.assertNotNull("Resulting set is unexpectedly null", result);
+        return result;
+    }
+}
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesHttpFetchBasicAuthTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesHttpFetchBasicAuthTest.java
new file mode 100644
index 0000000..0740e53
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesHttpFetchBasicAuthTest.java
@@ -0,0 +1,51 @@
+package com.vackosar.gitflowincrementalbuild.control;
+
+import static com.vackosar.gitflowincrementalbuild.mocks.server.TestServerType.HTTP_PROTOCOL_BASIC_AUTH;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.ResetCommand;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DifferentFilesHttpFetchBasicAuthTest extends BaseDifferentFilesTest {
+
+    public DifferentFilesHttpFetchBasicAuthTest() {
+        super(HTTP_PROTOCOL_BASIC_AUTH);
+    }
+
+    @Before
+    public void configureCredentialHelper() throws IOException {
+        StoredConfig config = localRepoMock.getGit().getRepository().getConfig();
+        config.setString("credential", null, "helper", "store");
+        config.save();
+    }
+
+    public void unsetCredentialHelper() throws IOException {
+        StoredConfig config = localRepoMock.getGit().getRepository().getConfig();
+        config.unset("credential", null, "helper");
+        config.save();
+    }
+
+    @Test
+    public void fetch() throws Exception {
+        addCommitToRemoteRepo(FETCH_FILE);
+        projectProperties.setProperty(Property.fetchReferenceBranch.fullName(), "true");
+        projectProperties.setProperty(Property.referenceBranch.fullName(), REMOTE_DEVELOP);
+        Files.write(nativeGitUserHome.resolve(".git-credentials"), buildCredentialsFileContent().getBytes());
+
+        invokeUnderTest();
+
+        Git localGit = localRepoMock.getGit();
+        localGit.reset().setMode(ResetCommand.ResetType.HARD).call();
+        localGit.checkout().setName(REMOTE_DEVELOP).call();
+        assertCommitExists(FETCH_FILE, localGit);
+    }
+
+    private String buildCredentialsFileContent() {
+        String usrPass = HTTP_PROTOCOL_BASIC_AUTH.getUsername() + ":" + HTTP_PROTOCOL_BASIC_AUTH.getPassword() + "@";
+        return localRepoMock.getRemoteRepo().repoUrl.replace("://", "://" + usrPass);
+    }
+}
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesHttpFetchTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesHttpFetchTest.java
new file mode 100644
index 0000000..6509038
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesHttpFetchTest.java
@@ -0,0 +1,27 @@
+package com.vackosar.gitflowincrementalbuild.control;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.ResetCommand;
+import org.junit.Test;
+import com.vackosar.gitflowincrementalbuild.mocks.server.TestServerType;
+
+public class DifferentFilesHttpFetchTest extends BaseDifferentFilesTest {
+
+    public DifferentFilesHttpFetchTest() {
+        super(TestServerType.HTTP_PROTOCOL);
+    }
+
+    @Test
+    public void fetch() throws Exception {
+        addCommitToRemoteRepo(FETCH_FILE);
+        projectProperties.setProperty(Property.fetchReferenceBranch.fullName(), "true");
+        projectProperties.setProperty(Property.referenceBranch.fullName(), REMOTE_DEVELOP);
+
+        invokeUnderTest();
+
+        Git localGit = localRepoMock.getGit();
+        localGit.reset().setMode(ResetCommand.ResetType.HARD).call();
+        localGit.checkout().setName(REMOTE_DEVELOP).call();
+        assertCommitExists(FETCH_FILE, localGit);
+    }
+}
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesTest.java
index 340b0b6..cfc1eeb 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesTest.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/control/DifferentFilesTest.java
@@ -14,32 +14,21 @@ import java.util.HashSet;
 import java.util.Set;
 import java.util.regex.Pattern;
 
-import org.apache.maven.execution.MavenSession;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.ResetCommand;
 import org.junit.Assert;
 import org.junit.Test;
-import org.powermock.reflect.Whitebox;
-import org.slf4j.Logger;
-
-import com.vackosar.gitflowincrementalbuild.BaseRepoTest;
-import com.vackosar.gitflowincrementalbuild.LoggerSpyUtil;
-import com.vackosar.gitflowincrementalbuild.boundary.Configuration;
 import com.vackosar.gitflowincrementalbuild.entity.SkipExecutionException;
 import com.vackosar.gitflowincrementalbuild.mocks.MavenSessionMock;
+import com.vackosar.gitflowincrementalbuild.mocks.server.TestServerType;
 
-public class DifferentFilesTest extends BaseRepoTest {
+public class DifferentFilesTest extends BaseDifferentFilesTest {
 
     private static final String REFS_HEADS_FEATURE_2 = "refs/heads/feature/2";
     private static final String HEAD = "HEAD";
-    private static final String FETCH_FILE = "fetch-file";
-    private static final String DEVELOP = "refs/heads/develop";
-    private static final String REMOTE_DEVELOP = "refs/remotes/origin/develop";
-
-    private Logger loggerSpy = LoggerSpyUtil.buildSpiedLoggerFor(DifferentFiles.class);
 
     public DifferentFilesTest() {
-        super(/* useSymLinkedFolder */ false, /* withRemote */ true);
+        super(TestServerType.GIT_PROTOCOL);
     }
 
     @Test(expected = SkipExecutionException.class)
@@ -171,13 +160,7 @@ public class DifferentFilesTest extends BaseRepoTest {
 
     @Test
     public void fetch() throws Exception {
-        Git remoteGit = localRepoMock.getRemoteRepo().getGit();
-        remoteGit.reset().setMode(ResetCommand.ResetType.HARD).call();
-        remoteGit.checkout().setName(DEVELOP).call();
-        remoteGit.getRepository().getDirectory().toPath().resolve(FETCH_FILE).toFile().createNewFile();
-        remoteGit.add().addFilepattern(".").call();
-        remoteGit.commit().setMessage(FETCH_FILE).call();
-        Assert.assertEquals(FETCH_FILE, remoteGit.log().setMaxCount(1).call().iterator().next().getFullMessage());
+        addCommitToRemoteRepo(FETCH_FILE);
         projectProperties.setProperty(Property.fetchReferenceBranch.fullName(), "true");
         projectProperties.setProperty(Property.referenceBranch.fullName(), REMOTE_DEVELOP);
 
@@ -186,21 +169,15 @@ public class DifferentFilesTest extends BaseRepoTest {
         Git localGit = localRepoMock.getGit();
         localGit.reset().setMode(ResetCommand.ResetType.HARD).call();
         localGit.checkout().setName(REMOTE_DEVELOP).call();
-        Assert.assertEquals(FETCH_FILE, localGit.log().setMaxCount(1).call().iterator().next().getFullMessage());
+        assertCommitExists(FETCH_FILE, localGit);
     }
 
     @Test
     public void fetchNonExistent() throws Exception {
-        Git remoteGit = localRepoMock.getRemoteRepo().getGit();
-        remoteGit.reset().setMode(ResetCommand.ResetType.HARD).call();
-        remoteGit.checkout().setName(DEVELOP).call();
-        remoteGit.getRepository().getDirectory().toPath().resolve(FETCH_FILE).toFile().createNewFile();
-        remoteGit.add().addFilepattern(".").call();
-        remoteGit.commit().setMessage(FETCH_FILE).call();
+        addCommitToRemoteRepo(FETCH_FILE);
         Git localGit = localRepoMock.getGit();
         localGit.branchDelete().setBranchNames(DEVELOP).call();
         localGit.branchDelete().setBranchNames(REMOTE_DEVELOP).call();
-        Assert.assertEquals(FETCH_FILE, remoteGit.log().setMaxCount(1).call().iterator().next().getFullMessage());
         projectProperties.setProperty(Property.fetchReferenceBranch.fullName(), "true");
         projectProperties.setProperty(Property.referenceBranch.fullName(), REMOTE_DEVELOP);
 
@@ -208,23 +185,7 @@ public class DifferentFilesTest extends BaseRepoTest {
 
         localGit.reset().setMode(ResetCommand.ResetType.HARD).call();
         localGit.checkout().setName(REMOTE_DEVELOP).call();
-        Assert.assertEquals(FETCH_FILE, localGit.log().setMaxCount(1).call().iterator().next().getFullMessage());
-    }
-
-    private Set<Path> invokeUnderTest() throws Exception {
-        return invokeUnderTest(getMavenSessionMock());
-    }
-
-    private Set<Path> invokeUnderTest(final MavenSession mavenSessionMock) throws Exception {
-        mavenSessionMock.getTopLevelProject().getProperties().putAll(projectProperties);
-
-        DifferentFiles underTest = new DifferentFiles();
-        Whitebox.setInternalState(underTest, mavenSessionMock, new Configuration.Provider(mavenSessionMock), loggerSpy);
-
-        Set<Path> result = underTest.get();
-
-        Assert.assertNotNull("Resulting set is unexpectedly null", result);
-        return result;
+        assertCommitExists(FETCH_FILE, localGit);
     }
 
     private Path modifyTrackedFile(Path repoPath) throws IOException {
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/LocalRepoMock.java b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/LocalRepoMock.java
index 3416aca..e06ec4c 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/LocalRepoMock.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/LocalRepoMock.java
@@ -8,6 +8,8 @@ import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteConfig;
 import org.eclipse.jgit.transport.URIish;
 
+import com.vackosar.gitflowincrementalbuild.mocks.server.TestServerType;
+
 import java.io.File;
 import java.io.IOException;
 import java.net.URISyntaxException;
@@ -23,15 +25,20 @@ public class LocalRepoMock implements AutoCloseable {
     private RemoteRepoMock remoteRepo;
     private Git git;
 
-    public LocalRepoMock(File baseFolder, boolean withRemote) throws IOException, URISyntaxException, GitAPIException {
+    public LocalRepoMock(File baseFolder, TestServerType remoteRepoServerType) throws IOException, URISyntaxException, GitAPIException {
         this.baseFolder = new File(baseFolder.getAbsolutePath(), "tmp/repo/");
         new UnZipper().act(templateProjectZip, this.baseFolder);
 
-        remoteRepo = withRemote ? new RemoteRepoMock(baseFolder, false) : null;
+        remoteRepo = remoteRepoServerType != null ? new RemoteRepoMock(baseFolder, remoteRepoServerType) : null;
         git = new Git(new FileRepository(new File(this.baseFolder, ".git")));
 
-        if (withRemote) {
-            configureRemote(git, remoteRepo.repoUrl);
+        if (remoteRepoServerType != null) {
+            try {
+                configureRemote(git, remoteRepo.repoUrl);
+            } catch (IOException | URISyntaxException | GitAPIException | RuntimeException e) {
+                close();
+                throw e;
+            }
         }
     }
 
@@ -62,16 +69,17 @@ public class LocalRepoMock implements AutoCloseable {
         remoteConfig.update(config);
 
         config.save();
-        git.fetch().call();
     }
 
     @Override
-    public void close() throws Exception {
+    public void close() {
         if (remoteRepo != null) {
             remoteRepo.close();
         }
-        git.getRepository().close();
-        git.close();
+        if (git != null) {
+            git.getRepository().close();
+            git.close();
+        }
     }
 
     public RemoteRepoMock getRemoteRepo() {
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/RemoteRepoMock.java b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/RemoteRepoMock.java
index 080d7e5..b4755ba 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/RemoteRepoMock.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/RemoteRepoMock.java
@@ -2,12 +2,12 @@ package com.vackosar.gitflowincrementalbuild.mocks;
 
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.transport.Daemon;
+
+import com.vackosar.gitflowincrementalbuild.mocks.server.TestServer;
+import com.vackosar.gitflowincrementalbuild.mocks.server.TestServerType;
 
 import java.io.File;
 import java.io.IOException;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
 
 public class RemoteRepoMock implements AutoCloseable {
 
@@ -20,9 +20,13 @@ public class RemoteRepoMock implements AutoCloseable {
     private final Git git;
     private final File repoFolder;
     private final File templateProjectZip = new File(getClass().getClassLoader().getResource("template.zip").getFile());
-    private final Daemon server;
+    private final TestServer testServer;
+
+    public RemoteRepoMock(File baseFolder, TestServerType testServerType) throws IOException {
+        this(baseFolder, false, testServerType);
+    }
 
-    public RemoteRepoMock(File baseFolder, boolean bare) throws IOException {
+    public RemoteRepoMock(File baseFolder, boolean bare, TestServerType testServerType) throws IOException {
         this.repoFolder = new File(baseFolder, "tmp/remote");
 
         if (bare) {
@@ -31,20 +35,13 @@ public class RemoteRepoMock implements AutoCloseable {
             unpackTemplateProject();
         }
 
-        server = start(repoFolder, bare);
-        repoUrl = String.format("git://%s:%s/repo.git", server.getAddress().getHostName(), server.getAddress().getPort());
-        git = new Git(new FileRepository(new File(repoFolder, ".git")));
-    }
-
-    private static Daemon start(File repoFolder, boolean bare) {
+        testServer = testServerType.buildServer();
+        repoUrl = testServer.start(repoFolder);
         try {
-            Daemon server = new Daemon(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0));
-            server.getService("git-receive-pack").setEnabled(true);
-            server.setRepositoryResolver(new RepoResolver(repoFolder, bare));
-            server.start();
-            return server;
-        } catch (IOException e) {
-            throw new RuntimeException("Failed to start JGit daemon for repo at: " + repoFolder, e);
+            git = new Git(new FileRepository(new File(repoFolder, ".git")));
+        } catch (IOException | RuntimeException e) {
+            close();
+            throw e;
         }
     }
 
@@ -53,10 +50,14 @@ public class RemoteRepoMock implements AutoCloseable {
     }
 
     @Override
-    public void close() throws Exception {
-        server.stop();
-        git.getRepository().close();
-        git.close();
+    public void close() {
+        if (testServer != null) {
+            testServer.stop();
+        }
+        if (git != null) {
+            git.getRepository().close();
+            git.close();
+        }
     }
 
     public Git getGit() {
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/GitProtocolServer.java b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/GitProtocolServer.java
new file mode 100644
index 0000000..5cb7a81
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/GitProtocolServer.java
@@ -0,0 +1,28 @@
+package com.vackosar.gitflowincrementalbuild.mocks.server;
+
+import java.io.File;
+import java.io.IOException;
+import org.eclipse.jgit.transport.Daemon;
+
+class GitProtocolServer implements TestServer {
+
+    private Daemon server;
+
+    @Override
+    public String start(File repoFolder) {
+        server = new Daemon(TestServerUtils.buildRandomLocalPortAddress());
+        server.getService("git-receive-pack").setEnabled(true);
+        server.setRepositoryResolver(new RepoResolver<>(repoFolder));
+        try {
+            server.start();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to start JGit daemon for repo at: " + repoFolder, e);
+        }
+        return TestServerUtils.buildRepoUrl("git", server.getAddress());
+    }
+
+    @Override
+    public void stop() {
+        server.stop();
+    }
+}
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/HttpProtocolServer.java b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/HttpProtocolServer.java
new file mode 100644
index 0000000..7f5f9f6
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/HttpProtocolServer.java
@@ -0,0 +1,113 @@
+package com.vackosar.gitflowincrementalbuild.mocks.server;
+
+import java.io.File;
+import java.net.InetSocketAddress;
+import java.util.Collections;
+
+import org.eclipse.jetty.security.ConstraintMapping;
+import org.eclipse.jetty.security.ConstraintSecurityHandler;
+import org.eclipse.jetty.security.HashLoginService;
+import org.eclipse.jetty.security.UserStore;
+import org.eclipse.jetty.security.authentication.BasicAuthenticator;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.util.security.Constraint;
+import org.eclipse.jetty.util.security.Password;
+import org.eclipse.jgit.http.server.GitServlet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class HttpProtocolServer implements TestServer {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(HttpProtocolServer.class);
+
+    private static final String[] ROLES = new String[] { "can-access" };
+
+    private final String username;
+    private final String password;
+
+    private Server server;
+
+    public HttpProtocolServer() {
+        this(null, null);
+    }
+
+    public HttpProtocolServer(String username, String password) {
+        this.username = username;
+        this.password = password;
+    }
+
+    @Override
+    public String start(File repoFolder) {
+        InetSocketAddress address = TestServerUtils.buildRandomLocalPortAddress();
+
+        server = new Server(address);
+        configureServer(server, repoFolder);
+
+        try {
+            server.start();
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to start Jetty servlet container for repo at: " + repoFolder, e);
+        }
+        return TestServerUtils.buildRepoUrl("http", address, server.getURI().getPort());
+    }
+
+    private void configureServer(Server server, File repoFolder) {
+
+        server.setHandler(buildServletHandler(repoFolder));
+        
+        if (username != null) {
+            addBasicAuth(server);
+        }
+    }
+
+    private ServletHandler buildServletHandler(File repoFolder) {
+        GitServlet gitServlet = new GitServlet();
+        gitServlet.setRepositoryResolver(new RepoResolver<>(repoFolder));
+
+        ServletHolder holder = new ServletHolder(gitServlet);
+        ServletHandler servletHandler = new ServletHandler();
+        servletHandler.addServletWithMapping(holder, "/*");
+        return servletHandler;
+    }
+
+    // https://www.eclipse.org/jetty/documentation/current/configuring-security.html#_authentication_and_authorization_with_embedded_jetty
+    private void addBasicAuth(Server server) {
+        
+        ConstraintSecurityHandler security = new ConstraintSecurityHandler();
+        security.setAuthenticator(new BasicAuthenticator());
+
+        Constraint constraint = new Constraint();
+        constraint.setAuthenticate(true);
+        constraint.setRoles(ROLES);
+        ConstraintMapping mapping = new ConstraintMapping();
+        mapping.setPathSpec("/*");
+        mapping.setConstraint(constraint);
+        security.setConstraintMappings(Collections.singletonList(mapping));
+
+        HashLoginService loginService = new HashLoginService();
+        loginService.setUserStore(buildUserStore());
+        server.addBean(loginService);
+        security.setLoginService(loginService);
+
+        security.setHandler(server.getHandler());
+        server.setHandler(security);
+    }
+
+    private UserStore buildUserStore() {
+        UserStore userStore = new UserStore();
+        userStore.addUser(username, new Password(password), ROLES);
+        return userStore;
+    }
+
+    @Override
+    public void stop() {
+        try {
+            server.stop();
+        } catch (Exception e) {
+            LOGGER.warn("Failed to stop Jetty servlet container", e);
+        }
+    }
+
+}
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/RepoResolver.java b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/RepoResolver.java
similarity index 89%
rename from src/test/java/com/vackosar/gitflowincrementalbuild/mocks/RepoResolver.java
rename to src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/RepoResolver.java
index 43771fd..8e82785 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/RepoResolver.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/RepoResolver.java
@@ -1,9 +1,8 @@
-package com.vackosar.gitflowincrementalbuild.mocks;
+package com.vackosar.gitflowincrementalbuild.mocks.server;
 
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
-import org.eclipse.jgit.transport.DaemonClient;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.resolver.RepositoryResolver;
 import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
@@ -14,20 +13,24 @@ import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 
-public class RepoResolver implements RepositoryResolver<DaemonClient> {
+class RepoResolver<C> implements RepositoryResolver<C> {
 
     private static final String DOT_GIT = ".git";
     private Map<String, Repository> repositories = new HashMap<>();
     private final File repoDir;
     private final boolean bare;
 
+    public RepoResolver(File repoDir) {
+        this(repoDir, false);
+    }
+
     public RepoResolver(File repoDir, boolean bare) {
         this.repoDir = repoDir;
         this.bare = bare;
     }
 
     @Override
-    public Repository open(DaemonClient client, String name)
+    public Repository open(C client, String name)
             throws RepositoryNotFoundException,
             ServiceNotAuthorizedException, ServiceNotEnabledException,
             ServiceMayNotContinueException {
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServer.java b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServer.java
new file mode 100644
index 0000000..ac2300f
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServer.java
@@ -0,0 +1,10 @@
+package com.vackosar.gitflowincrementalbuild.mocks.server;
+
+import java.io.File;
+
+public interface TestServer {
+
+    String start(File repoFolder);
+
+    void stop();
+}
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServerType.java b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServerType.java
new file mode 100644
index 0000000..b6cb5cc
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServerType.java
@@ -0,0 +1,43 @@
+package com.vackosar.gitflowincrementalbuild.mocks.server;
+
+public enum TestServerType {
+
+    GIT_PROTOCOL {
+        @Override
+        public TestServer buildServer() {
+            return new GitProtocolServer();
+        }
+    },
+    HTTP_PROTOCOL {
+        @Override
+        public TestServer buildServer() {
+            return new HttpProtocolServer();
+        }
+    },
+    HTTP_PROTOCOL_BASIC_AUTH {
+        @Override
+        public TestServer buildServer() {
+            return new HttpProtocolServer(getUsername(), getPassword());
+        }
+
+        @Override
+        public String getUsername() {
+            return "foo";
+        }
+
+        @Override
+        public String getPassword() {
+            return "bar";
+        }
+    };
+
+    public abstract TestServer buildServer();
+
+    public String getUsername() {
+        return null;
+    }
+
+    public String getPassword() {
+        return null;
+    }
+}
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServerUtils.java b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServerUtils.java
new file mode 100644
index 0000000..2808baa
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/mocks/server/TestServerUtils.java
@@ -0,0 +1,19 @@
+package com.vackosar.gitflowincrementalbuild.mocks.server;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+
+public class TestServerUtils {
+
+    static InetSocketAddress buildRandomLocalPortAddress() {
+        return new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);
+    }
+
+    static String buildRepoUrl(String protocol, InetSocketAddress address) {
+        return String.format("%s://%s:%s/repo.git", protocol, address.getHostName(), address.getPort());
+    }
+
+    static String buildRepoUrl(String protocol, InetSocketAddress address, int port) {
+        return String.format("%s://%s:%s/repo.git", protocol, address.getHostName(), port);
+    }
+}