diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3013da1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.distribugit_tmp/ +.distribugit_test_tmp/ +.idea/ +target/ +*.iml \ No newline at end of file diff --git a/pom.xml b/pom.xml index 7f1d4e2..9e4afbf 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ nl.andrewl distribugit - 1.2.0 + 1.3.0 17 @@ -46,6 +46,21 @@ picocli 4.6.3 + + + + org.junit.jupiter + junit-jupiter-api + 5.8.2 + test + + + + org.junit.jupiter + junit-jupiter-engine + 5.8.2 + test + diff --git a/src/main/java/nl/andrewl/distribugit/DistribuGit.java b/src/main/java/nl/andrewl/distribugit/DistribuGit.java index db9dced..445d9d7 100644 --- a/src/main/java/nl/andrewl/distribugit/DistribuGit.java +++ b/src/main/java/nl/andrewl/distribugit/DistribuGit.java @@ -9,6 +9,7 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ForkJoinPool; @@ -120,7 +121,7 @@ public class DistribuGit { if (finalizationAction != null) { applyActionToRepositories(repos, finalizationAction); } - repos.values().forEach(Git::close); + repos.values().stream().filter(Objects::nonNull).forEach(Git::close); } catch (Exception e) { throw new IOException(e); } finally { diff --git a/src/main/java/nl/andrewl/distribugit/RepositorySelector.java b/src/main/java/nl/andrewl/distribugit/RepositorySelector.java index b98452e..b67f5a8 100644 --- a/src/main/java/nl/andrewl/distribugit/RepositorySelector.java +++ b/src/main/java/nl/andrewl/distribugit/RepositorySelector.java @@ -23,4 +23,12 @@ public interface RepositorySelector { static RepositorySelector from(String... uris) { return () -> List.of(uris); } + + static RepositorySelector from(RepositorySelector... selectors) { + return () -> { + List uris = new ArrayList<>(); + for (var selector : selectors) uris.addAll(selector.getURIs()); + return uris; + }; + } } diff --git a/src/main/java/nl/andrewl/distribugit/cli/DistribuGitCommand.java b/src/main/java/nl/andrewl/distribugit/cli/DistribuGitCommand.java index 0d0d4c4..ba3dcca 100644 --- a/src/main/java/nl/andrewl/distribugit/cli/DistribuGitCommand.java +++ b/src/main/java/nl/andrewl/distribugit/cli/DistribuGitCommand.java @@ -1,40 +1,46 @@ package nl.andrewl.distribugit.cli; -import nl.andrewl.distribugit.*; -import nl.andrewl.distribugit.selectors.GitHubSelectorBuilder; +import nl.andrewl.distribugit.DistribuGit; +import nl.andrewl.distribugit.GitCredentials; +import nl.andrewl.distribugit.RepositoryAction; +import nl.andrewl.distribugit.StatusListener; import picocli.CommandLine; -import java.io.IOException; import java.nio.file.Path; import java.util.concurrent.Callable; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -@CommandLine.Command(name = "distribugit") +@CommandLine.Command(name = "distribugit", description = "DistribuGit command-line tool for performing distributed git operations.", mixinStandardHelpOptions = true) public class DistribuGitCommand implements Callable { - private static final Pattern SELECTOR_EXPRESSION_PATTERN = Pattern.compile("([\\w-]+):(.+)"); - private static final Pattern ORG_REPO_PREFIX_PATTERN = Pattern.compile("(.+)/(.+)"); - @CommandLine.Option(names = {"-d", "--dir"}, description = "The working directory for DistribuGit", defaultValue = "./.distribugit_tmp") - private Path workingDir; + public Path workingDir; - @CommandLine.Option(names = {"-s", "--selector"}, description = "The repository selector to use. Format: \"slug:content\"", required = true) - private String selectorExpression; + @CommandLine.Option( + names = {"-s", "--selector"}, + description = """ + The repository selector to use. + The following selector types are permitted: + - "org-repo-prefix:{orgName}/{repoPrefix}": Selects repositories from a GitHub organization whose name begins with the given prefix. + - "stdin": Selects repository URIs that have been provided to the program via stdin, with one URI per line. + - "file:{filePath}": Selects repository URIs written in a file, with one URI per line. + """, + required = true + ) + public String selectorExpression; @CommandLine.Option(names = {"-a", "--action"}, description = "The command to run on each repository.", required = true) - private String actionCommand; + public String actionCommand; @CommandLine.Option(names = {"-fa", "--finalization-action"}, description = "A command to run on each repository after all normal actions.") - private String finalizationActionCommand; + public String finalizationActionCommand; @CommandLine.Option(names = {"-t", "--access-token"}, description = "The access token to use to perform operations.") - private String accessToken; + public String accessToken; @CommandLine.Option(names = {"-sf", "--strict-fail"}, description = "Whether to preemptively fail if any error occurs.", defaultValue = "true") - private boolean strictFail; + public boolean strictFail; @CommandLine.Option(names = {"-cl", "--cleanup"}, description = "Whether to remove all repository files when done.", defaultValue = "false") - private boolean cleanup; + public boolean cleanup; @Override public Integer call() throws Exception { @@ -42,7 +48,7 @@ public class DistribuGitCommand implements Callable { .workingDir(workingDir) .strictFail(strictFail) .cleanup(cleanup) - .selector(parseSelectorExpression(selectorExpression)) + .selector(SelectorExpressionParser.parse(selectorExpression, accessToken)) .action(RepositoryAction.ofCommand(actionCommand.split("\\s+"))); if (finalizationActionCommand != null) { builder.finalizationAction(RepositoryAction.ofCommand(finalizationActionCommand.split("\\s+"))); @@ -65,20 +71,6 @@ public class DistribuGitCommand implements Callable { return 0; } - private RepositorySelector parseSelectorExpression(String expr) throws IOException { - Matcher m = SELECTOR_EXPRESSION_PATTERN.matcher(expr); - if (!m.find()) throw new IllegalArgumentException("Invalid selector expression. Should be \"selector-type:expression\"."); - String slug = m.group(1); - String content = m.group(2); - if (slug.equalsIgnoreCase("org-repo-prefix")) { - Matcher m1 = ORG_REPO_PREFIX_PATTERN.matcher(content); - if (!m1.find()) throw new IllegalArgumentException("Invalid content for org-repo-prefix select. Should be \"orgName/prefix\""); - return GitHubSelectorBuilder.fromPersonalAccessToken(accessToken).orgAndPrefix(m1.group(1), m1.group(2)); - } else { - throw new IllegalArgumentException("Unsupported selector type: \"" + slug + "\"."); - } - } - public static void main(String[] args) { new CommandLine(new DistribuGitCommand()).execute(args); } diff --git a/src/main/java/nl/andrewl/distribugit/cli/SelectorExpressionParser.java b/src/main/java/nl/andrewl/distribugit/cli/SelectorExpressionParser.java new file mode 100644 index 0000000..99ba71e --- /dev/null +++ b/src/main/java/nl/andrewl/distribugit/cli/SelectorExpressionParser.java @@ -0,0 +1,77 @@ +package nl.andrewl.distribugit.cli; + +import nl.andrewl.distribugit.RepositorySelector; +import nl.andrewl.distribugit.selectors.GitHubSelectorBuilder; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SelectorExpressionParser { + private static final Pattern SELECTOR_EXPRESSION_PATTERN = Pattern.compile("([\\w-]+):(.+)"); + private static final Pattern ORG_REPO_PREFIX_PATTERN = Pattern.compile("(.+)/(.+)"); + + public static RepositorySelector parse(String expr, String accessToken) throws IOException { + Matcher m = SELECTOR_EXPRESSION_PATTERN.matcher(expr); + if (!m.find()) throw new IllegalArgumentException("Invalid selector expression. Should be \"selector-type[:expression]\"."); + String slug = m.group(1); + String content = m.groupCount() > 1 ? m.group(2) : null; + return switch (slug) { + case "org-repo-prefix" -> parseOrgRepoPrefix(content, accessToken); + case "stdin" -> stdinSelector(); + case "file" -> fileSelector(content); + default -> throw new IllegalArgumentException("Unsupported selector type: " + slug); + }; + } + + private static RepositorySelector parseOrgRepoPrefix(String content, String accessToken) throws IOException { + if (content == null) throw new IllegalArgumentException("Missing required selector expression."); + if (accessToken == null) throw new IllegalArgumentException("Missing required access-token for GitHub org-repo-prefix selector."); + Matcher m = ORG_REPO_PREFIX_PATTERN.matcher(content); + if (!m.find()) throw new IllegalArgumentException("Invalid content for org-repo-prefix select. Should be \"orgName/prefix\""); + return GitHubSelectorBuilder.fromPersonalAccessToken(accessToken).orgAndPrefix(m.group(1), m.group(2)); + } + + private static RepositorySelector stdinSelector() { + return () -> { + List uris = new ArrayList<>(); + String line; + try (var reader = new BufferedReader(new InputStreamReader(System.in))) { + while ((line = reader.readLine()) != null) { + if (!line.isBlank()) { + uris.add(line.trim()); + } + } + } + return uris; + }; + } + + private static RepositorySelector fileSelector(String content) { + if (content == null) throw new IllegalArgumentException("No file paths were given."); + String[] filePaths = content.split(";"); + if (filePaths.length < 1) throw new IllegalArgumentException("No file paths were given."); + List paths = Arrays.stream(filePaths).map(Path::of).toList(); + for (var path : paths) { + if (Files.notExists(path)) throw new IllegalArgumentException("File " + path + " does not exist."); + if (!Files.isRegularFile(path)) throw new IllegalArgumentException("File " + path + " is not a regular file."); + if (!Files.isReadable(path)) throw new IllegalArgumentException("File " + path + " is not readable."); + } + return () -> { + List uris = new ArrayList<>(); + for (var path : paths) { + try (var s = Files.lines(path)) { + uris.addAll(s.filter(str -> !str.isBlank()).toList()); + } + } + return uris; + }; + } +} diff --git a/src/test/java/nl/andrewl/distribugit/DistribuGitTest.java b/src/test/java/nl/andrewl/distribugit/DistribuGitTest.java new file mode 100644 index 0000000..d1c414b --- /dev/null +++ b/src/test/java/nl/andrewl/distribugit/DistribuGitTest.java @@ -0,0 +1,79 @@ +package nl.andrewl.distribugit; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class DistribuGitTest { + @Test + public void testBasicActionOperation() throws IOException { + AtomicInteger count = new AtomicInteger(0); + RepositoryAction countAction = git -> count.incrementAndGet(); + new DistribuGit.Builder() + .selector(RepositorySelector.from( + "https://github.com/andrewlalis/record-net.git", + "https://github.com/andrewlalis/distribugit.git", + "https://github.com/andrewlalis/RandomHotbar.git" + )) + .action(countAction) + .statusListener(noOpListener()) + .strictFail(true) + .cleanup(true) + .workingDir(Path.of(".", ".distribugit_test_tmp")) + .build().doActions(); + assertEquals(3, count.get()); + } + + @Test + public void testStrictFailFalse() throws IOException { + AtomicInteger count = new AtomicInteger(0); + RepositoryAction countAction = git -> count.incrementAndGet(); + new DistribuGit.Builder() + .selector(RepositorySelector.from( + "https://github.com/andrewlalis/record-net.git", + "https://github.com/andrewlalis/distribugit.git", + "https://github.com/andrewlalis/somerandomgitrepositorythatdoesntexist.git" + )) + .action(countAction) + .statusListener(noOpListener()) + .strictFail(false) + .cleanup(true) + .workingDir(Path.of(".", ".distribugit_test_tmp")) + .build().doActions(); + assertEquals(2, count.get()); + } + + @Test + public void testStrictFailTrue() { + AtomicInteger count = new AtomicInteger(0); + RepositoryAction countAction = git -> count.incrementAndGet(); + DistribuGit d = new DistribuGit.Builder() + .selector(RepositorySelector.from( + "https://github.com/andrewlalis/record-net.git", + "https://github.com/andrewlalis/distribugit.git", + "https://github.com/andrewlalis/somerandomgitrepositorythatdoesntexist.git" + )) + .action(countAction) + .statusListener(noOpListener()) + .strictFail(true) + .cleanup(true) + .workingDir(Path.of(".", ".distribugit_test_tmp")) + .build(); + assertThrows(IOException.class, d::doActions); + } + + private static StatusListener noOpListener() { + return new StatusListener() { + @Override + public void progressUpdated(float percentage) {} + + @Override + public void messageReceived(String message) {} + }; + } +}