diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..149ef2e
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,38 @@
+
+
+ 4.0.0
+
+ nl.andrewl
+ distribugit
+ 1.0-SNAPSHOT
+
+
+ 17
+ 17
+ UTF-8
+
+
+
+
+
+ org.eclipse.jgit
+ org.eclipse.jgit
+ 6.1.0.202203080745-r
+
+
+
+ org.eclipse.jgit
+ org.eclipse.jgit.ssh.jsch
+ 6.1.0.202203080745-r
+
+
+
+ org.slf4j
+ slf4j-simple
+ 1.7.36
+ runtime
+
+
+
\ No newline at end of file
diff --git a/src/main/java/nl/andrewl/distribugit/DistribuGit.java b/src/main/java/nl/andrewl/distribugit/DistribuGit.java
new file mode 100644
index 0000000..c7ac9c7
--- /dev/null
+++ b/src/main/java/nl/andrewl/distribugit/DistribuGit.java
@@ -0,0 +1,191 @@
+package nl.andrewl.distribugit;
+
+import org.eclipse.jgit.api.CloneCommand;
+import org.eclipse.jgit.api.Git;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class DistribuGit {
+ private final RepositorySelector selector;
+ private final RepositoryAction action;
+ private final GitCredentials credentials;
+ private final StatusListener statusListener;
+ private final Path workingDir;
+ private final boolean strictFail;
+ private final boolean cleanup;
+
+ private int stepsComplete;
+ private int stepsTotal;
+
+ public DistribuGit(
+ RepositorySelector selector,
+ RepositoryAction action,
+ GitCredentials credentials,
+ StatusListener statusListener,
+ Path workingDir,
+ boolean strictFail,
+ boolean cleanup
+ ) {
+ this.selector = selector;
+ this.action = action;
+ this.credentials = credentials;
+ this.statusListener = statusListener;
+ this.workingDir = workingDir;
+ this.strictFail = strictFail;
+ this.cleanup = cleanup;
+ }
+
+ public void doActions() throws IOException {
+ stepsComplete = 0;
+ Utils.delete(workingDir); // Delete the directory if it already exists.
+ Files.createDirectory(workingDir);
+ statusListener.messageReceived("Prepared temporary directory for repositories.");
+ try {
+ List repositoryURIs = selector.getURIs();
+ stepsTotal = 2 * repositoryURIs.size();
+ Map repoDirs = downloadRepositories(repositoryURIs);
+ applyActionToRepositories(repoDirs);
+ } catch (Exception e) {
+ throw new IOException(e);
+ } finally {
+ if (cleanup) {
+ statusListener.messageReceived("Removing all repositories.");
+ Utils.delete(workingDir);
+ }
+ }
+ }
+
+ private void completeStep() {
+ stepsComplete++;
+ statusListener.progressUpdated(stepsComplete / (float) stepsTotal);
+ }
+
+ private Map downloadRepositories(List uris) throws IOException {
+ Map repositoryDirs = new HashMap<>();
+ int dirIdx = 1;
+ for (String repositoryURI : uris) {
+ Path repoDir = workingDir.resolve(Integer.toString(dirIdx++));
+ try {
+ statusListener.messageReceived("Cloning repository " + repositoryURI + " to " + repoDir);
+ CloneCommand clone = Git.cloneRepository();
+ credentials.addCredentials(clone);
+ clone.setDirectory(repoDir.toFile());
+ clone.setURI(repositoryURI);
+ try (var ignored = clone.call()) {
+ repositoryDirs.put(repositoryURI, repoDir);
+ } catch (Exception e) {
+ if (strictFail) {
+ throw new IOException(e);
+ } else {
+ repositoryDirs.put(repositoryURI, null);
+ e.printStackTrace();
+ }
+ }
+ } catch (Exception e) {
+ if (strictFail) {
+ throw new IOException(e);
+ }
+ e.printStackTrace();
+ }
+ completeStep();
+ }
+ return repositoryDirs;
+ }
+
+ private void applyActionToRepositories(Map repoDirs) throws IOException {
+ for (var entry : repoDirs.entrySet()) {
+ if (entry.getValue() != null) {
+ try (Git git = Git.open(entry.getValue().toFile())) {
+ statusListener.messageReceived("Applying action to repository " + entry.getKey());
+ action.doAction(git);
+ } catch (Exception e) {
+ if (strictFail) {
+ throw new IOException(e);
+ }
+ e.printStackTrace();
+ }
+ } else {
+ statusListener.messageReceived("Skipping action on repository " + entry.getKey() + " because it could not be downloaded.");
+ }
+ completeStep();
+ }
+ }
+
+ public static class Builder {
+ private RepositorySelector selector;
+ private RepositoryAction action;
+ private GitCredentials credentials = cmd -> {};
+ private StatusListener statusListener;
+ private Path workingDir = Path.of(".", ".distribugit_tmp");
+ private boolean strictFail = true;
+ private boolean cleanup = false;
+
+ public Builder selector(RepositorySelector selector) {
+ this.selector = selector;
+ return this;
+ }
+
+ public Builder action(RepositoryAction action) {
+ this.action = action;
+ return this;
+ }
+
+ public Builder credentials(GitCredentials credentials) {
+ this.credentials = credentials;
+ return this;
+ }
+
+ public Builder statusListener(StatusListener listener) {
+ this.statusListener = listener;
+ return this;
+ }
+
+ public Builder workingDir(Path dir) {
+ this.workingDir = dir;
+ return this;
+ }
+
+ public Builder strictFail(boolean strictFail) {
+ this.strictFail = strictFail;
+ return this;
+ }
+
+ public Builder cleanup(boolean cleanup) {
+ this.cleanup = cleanup;
+ return this;
+ }
+
+ public DistribuGit build() {
+ return new DistribuGit(selector, action, credentials, statusListener, workingDir, strictFail, cleanup);
+ }
+ }
+
+ public static void main(String[] args) throws IOException {
+ new Builder()
+ .selector(RepositorySelector.from(
+ "https://github.com/andrewlalis/RandomHotbar.git",
+ "https://github.com/andrewlalis/CoyoteCredit.git",
+ "https://github.com/andrewlalis/SignalsAndSystems2021.git"
+ ))
+ .action(git -> System.out.println("Cloned!"))
+ .statusListener(new StatusListener() {
+ @Override
+ public void progressUpdated(float percentage) {
+ System.out.printf("Progress: %.1f%%%n", percentage * 100);
+ }
+
+ @Override
+ public void messageReceived(String message) {
+ System.out.println("Message: " + message);
+ }
+ })
+ .strictFail(true)
+ .cleanup(true)
+ .build().doActions();
+ }
+}
diff --git a/src/main/java/nl/andrewl/distribugit/GitCredentials.java b/src/main/java/nl/andrewl/distribugit/GitCredentials.java
new file mode 100644
index 0000000..4aa182b
--- /dev/null
+++ b/src/main/java/nl/andrewl/distribugit/GitCredentials.java
@@ -0,0 +1,64 @@
+package nl.andrewl.distribugit;
+
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import org.eclipse.jgit.api.TransportCommand;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.SshTransport;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.eclipse.jgit.transport.ssh.jsch.JschConfigSessionFactory;
+import org.eclipse.jgit.transport.ssh.jsch.OpenSshConfig;
+import org.eclipse.jgit.util.FS;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+
+public interface GitCredentials {
+ void addCredentials(TransportCommand, ?> gitCommand) throws Exception;
+
+ static GitCredentials ofUsernamePassword(String username, String password) {
+ return cmd -> cmd.setCredentialsProvider(new UsernamePasswordCredentialsProvider(username, password));
+ }
+
+ static GitCredentials ofSshKey() {
+ return ofSshKey(Path.of(System.getProperty("user.home"), ".ssh", "id_rsa"));
+ }
+
+ static GitCredentials ofSshKey(Path privateKeyFile) {
+ return ofSshKey(
+ privateKeyFile,
+ privateKeyFile.getParent().resolve(privateKeyFile.getFileName().toString() + ".pub"),
+ null
+ );
+ }
+
+ static GitCredentials ofSshKey(Path privateKeyFile, Path publicKeyFile, String passphrase) {
+ System.out.println("Using private key at " + privateKeyFile.toAbsolutePath());
+ SshSessionFactory sshSessionFactory = new JschConfigSessionFactory() {
+ @Override
+ protected void configure(OpenSshConfig.Host hc, Session session) {
+ session.setConfig("StrictHostKeyChecking", "no"); // Don't require the host to be in the system's known hosts list.
+ }
+
+ @Override
+ protected JSch createDefaultJSch(FS fs) throws JSchException {
+ var jsch = super.createDefaultJSch(fs);
+ jsch.removeAllIdentity();
+ jsch.addIdentity(
+ privateKeyFile.toAbsolutePath().toString(),
+ publicKeyFile.toAbsolutePath().toString(),
+ passphrase == null ? null : passphrase.getBytes(StandardCharsets.UTF_8)
+ );
+ return jsch;
+ }
+ };
+ return cmd -> cmd.setTransportConfigCallback(transport -> {
+ if (transport instanceof SshTransport sshTransport) {
+ sshTransport.setSshSessionFactory(sshSessionFactory);
+ } else {
+ throw new IllegalStateException("Invalid git transport method: " + transport.getClass().getSimpleName() + "; Cannot apply SSH session factory to this type of transport.");
+ }
+ });
+ }
+}
diff --git a/src/main/java/nl/andrewl/distribugit/RepositoryAction.java b/src/main/java/nl/andrewl/distribugit/RepositoryAction.java
new file mode 100644
index 0000000..93c4f0d
--- /dev/null
+++ b/src/main/java/nl/andrewl/distribugit/RepositoryAction.java
@@ -0,0 +1,9 @@
+package nl.andrewl.distribugit;
+
+import org.eclipse.jgit.api.Git;
+
+public interface RepositoryAction {
+ void doAction(Git git) throws Exception;
+
+
+}
diff --git a/src/main/java/nl/andrewl/distribugit/RepositorySelector.java b/src/main/java/nl/andrewl/distribugit/RepositorySelector.java
new file mode 100644
index 0000000..8a052f9
--- /dev/null
+++ b/src/main/java/nl/andrewl/distribugit/RepositorySelector.java
@@ -0,0 +1,21 @@
+package nl.andrewl.distribugit;
+
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Component which produces a list of repositories to operate on.
+ */
+public interface RepositorySelector {
+ List getURIs() throws Exception;
+
+ static RepositorySelector fromCollection(Collection uris) {
+ return () -> new ArrayList<>(uris);
+ }
+
+ static RepositorySelector from(String... uris) {
+ return () -> List.of(uris);
+ }
+}
diff --git a/src/main/java/nl/andrewl/distribugit/StatusListener.java b/src/main/java/nl/andrewl/distribugit/StatusListener.java
new file mode 100644
index 0000000..3535185
--- /dev/null
+++ b/src/main/java/nl/andrewl/distribugit/StatusListener.java
@@ -0,0 +1,6 @@
+package nl.andrewl.distribugit;
+
+public interface StatusListener {
+ void progressUpdated(float percentage);
+ void messageReceived(String message);
+}
diff --git a/src/main/java/nl/andrewl/distribugit/Utils.java b/src/main/java/nl/andrewl/distribugit/Utils.java
new file mode 100644
index 0000000..ea554be
--- /dev/null
+++ b/src/main/java/nl/andrewl/distribugit/Utils.java
@@ -0,0 +1,40 @@
+package nl.andrewl.distribugit;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+
+public class Utils {
+ public static void deleteNoThrow(Path path) {
+ try {
+ delete(path);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public static void delete(Path path) throws IOException {
+ if (path == null) return;
+ if (!Files.exists(path)) return;
+ if (Files.isRegularFile(path)) {
+ Files.delete(path);
+ } else {
+ Files.walkFileTree(path, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+ Files.delete(file);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
+ Files.delete(dir);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+ }
+}