diff --git a/cli/src/main/java/org/openjdk/skara/cli/GitFork.java b/cli/src/main/java/org/openjdk/skara/cli/GitFork.java index d832e5873..d599f5ad5 100644 --- a/cli/src/main/java/org/openjdk/skara/cli/GitFork.java +++ b/cli/src/main/java/org/openjdk/skara/cli/GitFork.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -23,196 +23,133 @@ package org.openjdk.skara.cli; import org.openjdk.skara.args.*; -import org.openjdk.skara.forge.Forge; -import org.openjdk.skara.host.*; -import org.openjdk.skara.vcs.Repository; +import org.openjdk.skara.host.Credential; import org.openjdk.skara.proxy.HttpProxy; +import org.openjdk.skara.vcs.Repository; import org.openjdk.skara.version.Version; -import java.io.*; +import java.io.File; +import java.io.IOException; import java.net.URI; -import java.nio.file.*; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; -import java.util.*; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.function.Supplier; import java.util.logging.Level; public class GitFork { - private static void exit(String fmt, Object...args) { - System.err.println(String.format(fmt, args)); - System.exit(1); - } - - private static Supplier die(String fmt, Object... args) { - return () -> { - exit(fmt, args); - return null; - }; + private final Arguments arguments; + private final boolean isDryRun; + private final String sourceArg; + + public GitFork(Arguments arguments) { + this.arguments = arguments; + this.isDryRun = arguments.contains("dry-run"); + this.sourceArg = arguments.at(0).asString(); } - private static void sleep(int ms) { + private String gitConfig(String key) { try { - Thread.sleep(ms); - } catch (InterruptedException e) { - // do nothing + var pb = new ProcessBuilder("git", "config", key); + pb.redirectOutput(ProcessBuilder.Redirect.PIPE); + pb.redirectError(ProcessBuilder.Redirect.DISCARD); + var p = pb.start(); + + var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + var res = p.waitFor(); + if (res != 0) { + return null; + } + + return output.replace("\n", ""); + } catch (InterruptedException | IOException e) { + return null; } } - private static String getOption(String name, String subsection, Arguments arguments) { + private String getOption(String name) { if (arguments.contains(name)) { return arguments.get(name).asString(); } - if (subsection != null && !subsection.isEmpty()) { - var subsectionSpecific = gitConfig("fork." + subsection + "." + name); - if (subsectionSpecific != null) { - return subsectionSpecific; - } + var subsectionSpecific = gitConfig("fork." + sourceArg + "." + name); + if (subsectionSpecific != null) { + return subsectionSpecific; } return gitConfig("fork." + name); } - private static boolean getSwitch(String name, String subsection, Arguments arguments) { - if (arguments.contains(name)) { - return true; - } + private boolean getSwitch(String name) { + var option = getOption(name); + return option != null && option.equalsIgnoreCase("true"); + } + + private URI getURIFromArgs() { + var hostname = getOption("host"); - if (subsection != null && !subsection.isEmpty()) { - var subsectionSpecific = gitConfig("fork." + subsection + "." + name); - if (subsectionSpecific != null) { - return subsectionSpecific.toLowerCase().equals("true"); + try { + if (hostname != null) { + // Assume command line argument is just the path component + var extraSlash = sourceArg.startsWith("/") ? "" : "/"; + return new URI("https://" + hostname + extraSlash + sourceArg); + } else { + var uri = new URI(sourceArg); + if (uri.getScheme() == null) { + return new URI("https://" + uri.getHost() + uri.getPath()); + } else { + return uri; + } } + } catch (URISyntaxException e) { + exit("error: could not form a valid URI from argument: " + sourceArg); + return null; // make compiler quiet } - - var sectionSpecific = gitConfig("fork." + name); - return sectionSpecific != null && sectionSpecific.toLowerCase().equals("true"); } - private static String gitConfig(String key) { - try { - var pb = new ProcessBuilder("git", "config", key); - pb.redirectOutput(ProcessBuilder.Redirect.PIPE); - pb.redirectError(ProcessBuilder.Redirect.DISCARD); - var p = pb.start(); + private Path getTargetDir(URI cloneURI) { + if (arguments.at(1).isPresent()) { + // If user provided an explicit name for target dir, use it + return Path.of(arguments.at(1).asString()); + } else { + // Otherwise get the base name from the URI + var targetDir = Path.of(cloneURI.getPath()).getFileName(); + var targetDirStr = targetDir.toString(); - var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - var res = p.waitFor(); - if (res != 0) { - return null; + if (targetDirStr.endsWith(".git")) { + return Path.of(targetDirStr.substring(0, targetDirStr.length() - ".git".length())); + } else { + return targetDir; } - - return output == null ? null : output.replace("\n", ""); - } catch (InterruptedException e) { - return null; - } catch (IOException e) { - return null; } } - private static Repository clone(List args, String to, boolean isMercurial) throws IOException { + private Repository clone(List args, URI cloneURI, Path targetDir) throws IOException { try { - var vcs = isMercurial ? "hg" : "git"; var command = new ArrayList(); - command.add(vcs); + command.add("git"); command.add("clone"); command.addAll(args); - command.add(to); - var pb = new ProcessBuilder(command); - pb.inheritIO(); - var p = pb.start(); - var res = p.waitFor(); - if (res != 0) { - exit("error: '" + vcs + " clone " + String.join(" ", args) + "' failed with exit code: " + res); + command.add(cloneURI.toString()); + command.add(targetDir.toString()); + if (!isDryRun) { + var pb = new ProcessBuilder(command); + pb.inheritIO(); + var p = pb.start(); + var res = p.waitFor(); + if (res != 0) { + exit("error: '" + "git" + " clone " + String.join(" ", args) + "' failed with exit code: " + res); + } } - return Repository.get(Path.of(to)).orElseThrow(() -> new IOException("Could not find repository")); + return Repository.get(targetDir).orElseThrow(() -> new IOException("Could not find repository")); } catch (InterruptedException e) { throw new IOException(e); } } - public static void main(String[] args) throws IOException, InterruptedException { - var flags = List.of( - Option.shortcut("u") - .fullname("username") - .describe("NAME") - .helptext("Username on host") - .optional(), - Option.shortcut("") - .fullname("reference") - .describe("DIR") - .helptext("Same as git clone's flags 'reference-if-able' + 'dissociate'") - .optional(), - Option.shortcut("") - .fullname("depth") - .describe("N") - .helptext("Same as git clones flag 'depth'") - .optional(), - Option.shortcut("") - .fullname("shallow-since") - .describe("DATE") - .helptext("Same as git clones flag 'shallow-since'") - .optional(), - Switch.shortcut("") - .fullname("setup-pre-push-hook") - .helptext("Setup a pre-push hook that runs git-jcheck") - .optional(), - Option.shortcut("") - .fullname("host") - .describe("HOSTNAME") - .helptext("Hostname for the forge") - .optional(), - Switch.shortcut("") - .fullname("no-clone") - .helptext("Just fork the repository, do not clone it") - .optional(), - Switch.shortcut("") - .fullname("no-remote") - .helptext("Do not add an additional git remote") - .optional(), - Switch.shortcut("") - .fullname("ssh") - .helptext("Use the ssh:// protocol when cloning") - .optional(), - Switch.shortcut("") - .fullname("https") - .helptext("Use the https:// protocol when cloning") - .optional(), - Switch.shortcut("") - .fullname("sync") - .helptext("Sync with the upstream repository after successful fork") - .optional(), - Switch.shortcut("") - .fullname("verbose") - .helptext("Turn on verbose output") - .optional(), - Switch.shortcut("") - .fullname("debug") - .helptext("Turn on debugging output") - .optional(), - Switch.shortcut("") - .fullname("version") - .helptext("Print the version of this tool") - .optional(), - Switch.shortcut("") - .fullname("mercurial") - .helptext("Force use of mercurial") - .optional()); - - var inputs = List.of( - Input.position(0) - .describe("URI") - .singular() - .optional(), - Input.position(1) - .describe("NAME") - .singular() - .optional()); - - var parser = new ArgumentParser("git-fork", flags, inputs); - var arguments = parser.parse(args); - var isMercurial = arguments.contains("mercurial"); - + public void fork() throws IOException, InterruptedException { if (arguments.contains("version")) { System.out.println("git-fork version: " + Version.fromManifest().orElse("unknown")); System.exit(0); @@ -223,172 +160,246 @@ public static void main(String[] args) throws IOException, InterruptedException Logging.setup(level); } + if (isDryRun) { + System.out.println("Running in dry-run mode. No actual changes will be performed"); + } + HttpProxy.setup(); - var subsection = arguments.at(0).isPresent() ? arguments.at(0).asString() : null; + // Get the upstream repo user specified on the command line + var upstreamURI = getURIFromArgs(); + var upstreamWebURI = Remote.toWebURI(upstreamURI.toString()); + System.out.println("Creating fork of " + upstreamWebURI); + var credentials = setupCredentials(upstreamWebURI); - boolean useSSH = getSwitch("ssh", subsection, arguments); - boolean useHTTPS = getSwitch("https", subsection, arguments); - var hostname = getOption("host", subsection, arguments); + var gitForge = ForgeUtils.from(upstreamWebURI, credentials); + if (gitForge.isEmpty()) { + exit("error: could not connect to host " + upstreamWebURI.getHost()); + } - URI uri = null; - if (arguments.at(0).isPresent()) { - var arg = arguments.at(0).asString(); - if (hostname != null) { - var extraSlash = arg.startsWith("/") ? "" : "/"; - uri = URI.create("https://" + hostname + extraSlash + arg); - } else { - var argURI = URI.create(arg); - uri = argURI.getScheme() == null ? - URI.create("https://" + argURI.getHost() + argURI.getPath()) : - argURI; + var repositoryPath = getTrimmedPath(upstreamWebURI); + var upstreamHostedRepo = gitForge.get().repository(repositoryPath).orElseThrow(() -> + new IOException("Could not find repository at " + upstreamWebURI) + ); + + // Create personal fork ("origin" from now on) at Git Forge + var originHostedRepo = upstreamHostedRepo.fork(); + var originWebURI = originHostedRepo.webUrl(); + System.out.println("Personal fork available at " + originWebURI); + + if (getSwitch("no-clone")) { + // We're done here, if we should not create a local clone + logVerbose("Not cloning fork due to --no-clone"); + return; + } + + // Create a local clone + var cloneURI = getCloneURI(originWebURI); + System.out.println("Cloning personal fork..."); + var repo = clone(getCloneArgs(), cloneURI, getTargetDir(cloneURI)); + System.out.println("Done cloning"); + + // Setup git remote + if (!getSwitch("no-remote")) { + System.out.println("Adding remote 'upstream' for " + upstreamWebURI); + if (!isDryRun) { + repo.addRemote("upstream", upstreamWebURI.toString()); + } + } + + // Sync the fork from upstream + if (getSwitch("sync")) { + logVerbose("Syncing personal fork with upstream"); + var syncArgs = new ArrayList(); + syncArgs.add("--fast-forward"); + if (getSwitch("no-remote")) { + // Propagate --no-remote; and also specify the remote for git sync to work + syncArgs.add("--no-remote"); + syncArgs.add("--from"); + syncArgs.add(upstreamWebURI.toString()); + } + if (!isDryRun) { + GitSync.sync(repo, syncArgs.toArray(new String[] {})); } - } else { - var cwd = Path.of("").toAbsolutePath(); - var repo = Repository.get(cwd).orElseGet(die("error: no git repository found at " + cwd)); - uri = URI.create(repo.pullPath("origin")); } - if (uri == null) { - exit("error: not a valid URI: " + uri); + // Setup jcheck hooks + if (getSwitch("setup-pre-push-hook")) { + logVerbose("Setting up jcheck hooks"); + if (!isDryRun) { + var res = GitJCheck.run(repo, new String[] {"--setup-pre-push-hook"}); + if (res != 0) { + System.exit(res); + } + } } + } - var webURI = Remote.toWebURI(uri.toString()); - var token = isMercurial ? System.getenv("HG_TOKEN") : System.getenv("GIT_TOKEN"); - var username = getOption("username", subsection, arguments); - var credentials = GitCredentials.fill(webURI.getHost(), webURI.getPath(), username, token, webURI.getScheme()); + private Credential setupCredentials(URI upstreamWebURI) throws IOException { + var token = System.getenv("GIT_TOKEN"); + var username = getOption("username"); + + var credentials = GitCredentials.fill(upstreamWebURI.getHost(), upstreamWebURI.getPath(), username, token, upstreamWebURI.getScheme()); if (credentials.password() == null) { exit("error: no personal access token found, use git-credentials or the environment variable GIT_TOKEN"); } if (credentials.username() == null) { - exit("error: no username for " + webURI.getHost() + " found, use git-credentials or the flag --username"); + exit("error: no username for " + upstreamWebURI.getHost() + " found, use git-credentials or the flag --username"); + } + if (token == null) { + GitCredentials.approve(credentials); } + return new Credential(credentials.username(), credentials.password()); + } - var host = ForgeUtils.from(webURI, new Credential(credentials.username(), credentials.password())); - if (host.isEmpty()) { - exit("error: could not connect to host " + webURI.getHost()); + private URI getCloneURI(URI originWebURI) { + if (getSwitch("ssh")) { + return URI.create("ssh://git@" + originWebURI.getHost() + originWebURI.getPath() + ".git"); + } else { + return originWebURI; } + } - var repositoryPath = webURI.getPath().substring(1); + private ArrayList getCloneArgs() { + var cloneArgs = new ArrayList(); - if (repositoryPath.endsWith("/")) { - repositoryPath = - repositoryPath.substring(0, repositoryPath.length() - 1); + var reference = getOption("reference"); + if (reference != null) { + cloneArgs.add("--reference-if-able=" + expandPath(reference)); + cloneArgs.add("--dissociate"); } - var hostedRepo = host.get().repository(repositoryPath).orElseThrow(() -> - new IOException("Could not find repository at " + webURI.toString()) - ); - - var fork = hostedRepo.fork(); - if (token == null) { - GitCredentials.approve(credentials); + var depth = getOption("depth"); + if (depth != null) { + cloneArgs.add("--depth=" + depth); } - var forkWebUrl = fork.webUrl(); - if (isMercurial) { - forkWebUrl = URI.create("git+" + forkWebUrl.toString()); + var shallowSince = getOption("shallow-since"); + if (shallowSince != null) { + cloneArgs.add("--shallow-since=" + shallowSince); } - boolean noClone = getSwitch("no-clone", subsection, arguments); - boolean noRemote = getSwitch("no-remote", subsection, arguments); - boolean shouldSync = getSwitch("sync", subsection, arguments); - if (noClone || !arguments.at(0).isPresent()) { - if (!arguments.at(0).isPresent()) { - var cwd = Path.of("").toAbsolutePath(); - var repo = Repository.get(cwd).orElseGet(die("error: no git repository found at " + cwd)); - - var forkURL = useSSH ? - "ssh://git@" + forkWebUrl.getHost() + forkWebUrl.getPath() : - forkWebUrl.toString(); - System.out.println(forkURL); - - if (!noRemote) { - var remoteWord = isMercurial ? "path" : "remote"; - System.out.print("Adding " + remoteWord + " 'clone' for " + forkURL + "..."); - if (isMercurial) { - forkURL = "git+" + forkURL; - } - repo.addRemote("fork", forkURL); - System.out.println("done"); - - if (shouldSync) { - GitSync.sync(repo, new String[]{"--from", "origin", "--to", "fork"}); - } - } - } + return cloneArgs; + } + + private static String expandPath(String path) { + // FIXME: Why is this not done from the shell? It should not be needed. + if (path.startsWith("~" + File.separator)) { + return System.getProperty("user.home") + path.substring(1); } else { - var reference = getOption("reference", subsection, arguments); - if (reference != null && reference.startsWith("~" + File.separator)) { - reference = System.getProperty("user.home") + reference.substring(1); - } - var depth = getOption("depth", subsection, arguments); - var shallowSince = getOption("shallow-since", subsection, arguments); + return path; + } + } - URI cloneURI = null; - if (hostname != null) { - if (useSSH) { - cloneURI = URI.create("ssh://git@" + forkWebUrl.getHost() + forkWebUrl.getPath() + ".git"); - } else { - cloneURI = URI.create("https://" + forkWebUrl.getHost() + forkWebUrl.getPath()); - } - } else { - if (useSSH) { - cloneURI = URI.create("ssh://git@" + forkWebUrl.getHost() + forkWebUrl.getPath() + ".git"); - } else { - cloneURI = forkWebUrl; - } - } + private static String getTrimmedPath(URI uri) { + var repositoryPath = uri.getPath().substring(1); - System.out.println("Fork available at: " + forkWebUrl); - System.out.println("Cloning " + cloneURI + "..."); + if (repositoryPath.endsWith("/")) { + return repositoryPath.substring(0, repositoryPath.length() - 1); + } else { + return repositoryPath; + } + } - var cloneArgs = new ArrayList(); - if (reference != null) { - cloneArgs.add("--reference-if-able=" + reference); - cloneArgs.add("--dissociate"); - } - if (depth != null) { - cloneArgs.add("--depth=" + depth); - } - if (shallowSince != null) { - cloneArgs.add("--shallow-since=" + shallowSince); - } - cloneArgs.add(cloneURI.toString()); + private void logVerbose(String message) { + if (arguments.contains("verbose") || arguments.contains("debug")) { + System.out.println(message); + } + } - var defaultTo = Path.of(cloneURI.getPath()).getFileName().toString(); - if (defaultTo.endsWith(".git")) { - defaultTo = defaultTo.substring(0, defaultTo.length() - ".git".length()); - } - String to = arguments.at(1).isPresent() ? - arguments.at(1).asString() : - defaultTo; - var repo = clone(cloneArgs, to, isMercurial); - - if (!noRemote) { - var remoteWord = isMercurial ? "path" : "remote"; - System.out.print("Adding " + remoteWord + " 'upstream' for " + webURI.toString() + "..."); - var upstreamUrl = webURI.toString(); - if (isMercurial) { - upstreamUrl = "git+" + upstreamUrl; - } - repo.addRemote("upstream", upstreamUrl); + private static void exit(String message) { + System.err.println(message); + System.exit(1); + } - System.out.println("done"); + private static Supplier die(String message) { + return () -> { + exit(message); + return null; + }; + } - if (shouldSync) { - GitSync.sync(repo, new String[]{"--from", "upstream", "--to", "origin", "--fast-forward"}); - } + private static Arguments parseArguments(String[] args) { + var flags = List.of( + Option.shortcut("u") + .fullname("username") + .describe("NAME") + .helptext("Username on host") + .optional(), + Option.shortcut("") + .fullname("reference") + .describe("DIR") + .helptext("Same as the 'git clone' flags 'reference-if-able' + 'dissociate'") + .optional(), + Option.shortcut("") + .fullname("depth") + .describe("N") + .helptext("Same as the 'git clone' flag 'depth'") + .optional(), + Option.shortcut("") + .fullname("shallow-since") + .describe("DATE") + .helptext("Same as the 'git clone' flag 'shallow-since'") + .optional(), + Switch.shortcut("") + .fullname("setup-pre-push-hook") + .helptext("Setup a pre-push hook that runs git-jcheck") + .optional(), + Option.shortcut("") + .fullname("host") + .describe("HOSTNAME") + .helptext("Hostname for the forge") + .optional(), + Switch.shortcut("") + .fullname("no-clone") + .helptext("Just fork the repository, do not clone it") + .optional(), + Switch.shortcut("") + .fullname("no-remote") + .helptext("Do not add an upstream git remote") + .optional(), + Switch.shortcut("") + .fullname("ssh") + .helptext("Use the ssh:// protocol when cloning (instead of https)") + .optional(), + Switch.shortcut("") + .fullname("sync") + .helptext("Sync with the upstream repository after successful fork") + .optional(), + Switch.shortcut("n") + .fullname("dry-run") + .helptext("Only simulate behavior, do no actual changes") + .optional(), + Switch.shortcut("") + .fullname("verbose") + .helptext("Turn on verbose output") + .optional(), + Switch.shortcut("") + .fullname("debug") + .helptext("Turn on debugging output") + .optional(), + Switch.shortcut("") + .fullname("version") + .helptext("Print the version of this tool") + .optional()); - var setupPrePushHooksOption = getOption("setup-pre-push-hook", subsection, arguments); - if (setupPrePushHooksOption != null) { - var res = GitJCheck.run(repo, new String[]{"--setup-pre-push-hook"}); - if (res != 0) { - System.exit(res); - } - } - } - } + var inputs = List.of( + Input.position(0) + .describe("URI") + .singular() + .required(), + Input.position(1) + .describe("NAME") + .singular() + .optional()); + + var parser = new ArgumentParser("git fork", flags, inputs); + return parser.parse(args); + } + + public static void main(String[] args) throws IOException, InterruptedException { + GitFork commandExecutor = new GitFork(parseArguments(args)); + commandExecutor.fork(); } } diff --git a/cli/src/main/java/org/openjdk/skara/cli/GitSync.java b/cli/src/main/java/org/openjdk/skara/cli/GitSync.java index fd3263947..3a3ae7f6e 100644 --- a/cli/src/main/java/org/openjdk/skara/cli/GitSync.java +++ b/cli/src/main/java/org/openjdk/skara/cli/GitSync.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -24,7 +24,6 @@ import org.openjdk.skara.args.*; import org.openjdk.skara.vcs.*; -import org.openjdk.skara.forge.*; import org.openjdk.skara.proxy.HttpProxy; import org.openjdk.skara.version.Version; @@ -36,34 +35,44 @@ import java.util.logging.*; public class GitSync { - private static IOException die(String message) { - System.err.println(message); - System.exit(1); - return new IOException("will never reach here"); - } + private final Repository repo; + private final Arguments arguments; + private final List remotes; + private final boolean isDryRun; + private String targetName; + private URI targetURI; + private String sourceName; + private URI sourceURI; - private static int pull(Repository repo) throws IOException, InterruptedException { - var pb = new ProcessBuilder("git", "pull"); - pb.directory(repo.root().toFile()); - pb.inheritIO(); - return pb.start().waitFor(); + private GitSync(Repository repo, Arguments arguments) throws IOException { + this.repo = repo; + this.arguments = arguments; + this.remotes = repo.remotes(); + this.isDryRun = arguments.contains("dry-run"); } - private static int mergeFastForward(Repository repo, String ref) throws IOException, InterruptedException { - var pb = new ProcessBuilder("git", "merge", "--ff-only", "--quiet", ref); - pb.directory(repo.root().toFile()); - pb.inheritIO(); - return pb.start().waitFor(); + private void logVerbose(String message) { + if (arguments.contains("verbose") || arguments.contains("debug")) { + System.out.println(message); + } } - private static int moveBranch(Repository repo, Branch branch, Hash to) throws IOException, InterruptedException { - var pb = new ProcessBuilder("git", "branch", "--force", branch.name(), to.hex()); - pb.directory(repo.root().toFile()); - pb.inheritIO(); - return pb.start().waitFor(); + private URI getRemoteURI(String name) throws IOException { + if (name != null) { + if (remotes.contains(name)) { + return Remote.toURI(repo.pullPath(name)); + } else { + try { + return Remote.toURI(name); + } catch (IOException e) { + die(name + " is not a known git remote, nor a proper git URI"); + } + } + } + return null; } - private static String getOption(String name, Arguments arguments, ReadOnlyRepository repo) throws IOException { + private String getOption(String name) throws IOException { if (arguments.contains(name)) { return arguments.get(name).asString(); } @@ -72,177 +81,236 @@ private static String getOption(String name, Arguments arguments, ReadOnlyReposi return lines.size() == 1 ? lines.get(0) : null; } - static void sync(Repository repo, String[] args) throws IOException, InterruptedException { - var flags = List.of( - Option.shortcut("") - .fullname("from") - .describe("REMOTE") - .helptext("Fetch changes from this remote") - .optional(), - Option.shortcut("") - .fullname("to") - .describe("REMOTE") - .helptext("Push changes to this remote") - .optional(), - Option.shortcut("") - .fullname("branches") - .describe("BRANCHES") - .helptext("Comma separated list of branches to sync") - .optional(), - Option.shortcut("") - .fullname("ignore") - .describe("PATTERN") - .helptext("Regular expression of branches to ignore") - .optional(), - Option.shortcut("u") - .fullname("username") - .describe("NAME") - .helptext("Username on forge") - .optional(), - Switch.shortcut("") - .fullname("pull") - .helptext("Pull current branch from origin after successful sync") - .optional(), - Switch.shortcut("ff") - .fullname("fast-forward") - .helptext("Fast forward all local branches where possible") - .optional(), - Switch.shortcut("") - .fullname("verbose") - .helptext("Turn on verbose output") - .optional(), - Switch.shortcut("") - .fullname("debug") - .helptext("Turn on debugging output") - .optional(), - Switch.shortcut("v") - .fullname("version") - .helptext("Print the version of this tool") - .optional() - ); + private void syncBranch(String name) throws IOException { + Hash fetchHead = null; + logVerbose("Fetching branch " + name + " from " + sourceURI); + if (!isDryRun) { + fetchHead = repo.fetch(sourceURI, name); + } + logVerbose("Pushing to " + targetURI); + if (!isDryRun) { + repo.push(fetchHead, targetURI, name); + } + } - var parser = new ArgumentParser("git sync", flags); - var arguments = parser.parse(args); + private void fetchTarget() throws IOException { + if (isDryRun) return; - if (arguments.contains("version")) { - System.out.println("git-sync version: " + Version.fromManifest().orElse("unknown")); - System.exit(0); + repo.fetchRemote(targetName); + } + + private void pull() throws IOException, InterruptedException { + if (isDryRun) return; + + var pb = new ProcessBuilder("git", "pull"); + pb.directory(repo.root().toFile()); + pb.inheritIO(); + var result = pb.start().waitFor(); + if (result != 0) { + die("Failure running git pull, exit code " + result); } + } - if (arguments.contains("verbose") || arguments.contains("debug")) { - var level = arguments.contains("debug") ? Level.FINER : Level.FINE; - Logging.setup(level); + private void mergeFastForward(String ref) throws IOException, InterruptedException { + if (isDryRun) return; + + var pb = new ProcessBuilder("git", "merge", "--ff-only", "--quiet", ref); + pb.directory(repo.root().toFile()); + pb.inheritIO(); + var result = pb.start().waitFor(); + + if (result != 0) { + die("Failure running git merge, exit code " + result); } + } + private void moveBranch(Branch branch, Hash to) throws IOException, InterruptedException { + if (isDryRun) return; - HttpProxy.setup(); + var pb = new ProcessBuilder("git", "branch", "--force", branch.name(), to.hex()); + pb.directory(repo.root().toFile()); + pb.inheritIO(); + var result = pb.start().waitFor(); - var remotes = repo.remotes(); + if (result != 0) { + die("Failure running git branch, exit code " + result); + } + } - String from = null; - if (arguments.contains("from")) { - from = arguments.get("from").asString(); - } else { - var lines = repo.config("sync.from"); - if (lines.size() == 1 && remotes.contains(lines.get(0))) { - from = lines.get(0); + private void setupTargetAndSource() throws IOException { + String targetFromOptions = getOption("to"); + URI targetFromOptionsURI = getRemoteURI(targetFromOptions); + + String sourceFromOptions = getOption("from"); + URI sourceFromOptionsURI = getRemoteURI(sourceFromOptions); + + // Find push target repo + if (!remotes.contains("origin")) { + if (targetFromOptions != null) { + // If 'origin' is missing but we have command line arguments, use these instead + targetName = targetFromOptions; + targetURI = targetFromOptionsURI; } else { - if (remotes.contains("upstream")) { - from = "upstream"; - } else if (remotes.contains("origin")) { - if (remotes.contains("fork")) { - from = "origin"; + die("repo does not have an 'origin' remote defined"); + } + } else { + targetName = "origin"; + targetURI = Remote.toURI(repo.pullPath(targetName)); + if (targetFromOptions != null) { + if (!equalsCanonicalized(targetFromOptionsURI, targetURI)) { + if (arguments.contains("force")) { + logVerbose("Overriding target 'origin' with " + targetFromOptions + " due to --force"); + targetName = targetFromOptions; + targetURI = targetFromOptionsURI; } else { - var originPullPath = repo.pullPath("origin"); - try { - var uri = Remote.toWebURI(originPullPath); - from = ForgeUtils.from(uri) - .flatMap(f -> f.repository(uri.getPath().substring(1))) - .flatMap(r -> r.parent()) - .map(p -> p.webUrl().toString()) - .orElse(null); - } catch (Throwable e) { - from = null; - } + die("git 'origin' remote and '--to' argument differ. Consider using --force."); } } } } - if (from == null) { - System.err.println("error: could not find repository to sync from, please specify one with --from"); - System.err.println(" or add a remote named 'upstream'"); - System.exit(1); - } - - var fromPullPath = remotes.contains(from) ? - Remote.toURI(repo.pullPath(from)) : Remote.toURI(from); - var fromScheme = fromPullPath.getScheme(); - if (fromScheme.equals("https") || fromScheme.equals("http")) { - var token = System.getenv("GIT_TOKEN"); - var username = getOption("username", arguments, repo); - var credentials = GitCredentials.fill(fromPullPath.getHost(), - fromPullPath.getPath(), - username, - token, - fromScheme); - if (credentials.password() != null && credentials.username() != null && token != null) { - fromPullPath = URI.create(fromScheme + "://" + credentials.username() + ":" + credentials.password() + "@" + fromPullPath.getHost() + fromPullPath.getPath()); + // Find pull source as given by the Git Forge as the repository's parent + var forgeWebURI = Remote.toWebURI(targetURI.toString()); + URI sourceParentURI; + String sourceParentName; + try { + sourceParentURI = ForgeUtils.from(forgeWebURI) + .flatMap(f -> f.repository(forgeWebURI.getPath().substring(1))) + .flatMap(r -> r.parent()) + .map(p -> p.webUrl()) + .orElse(null); + sourceParentName = sourceParentURI.toString(); + logVerbose("Git Forge reports upstream parent is " + sourceParentURI); + } catch (Throwable e) { + if (arguments.contains("debug")) { + e.printStackTrace(); } + if (!arguments.contains("force")) { + // Unless we force a different recipient repo, we are not allowed to have an error here + die("cannot get parent repo from Git Forge provider for " + forgeWebURI); + } + sourceParentURI = null; + sourceParentName = null; } - String to = null; - if (arguments.contains("to")) { - to = arguments.get("to").asString(); - } else { - var lines = repo.config("sync.to"); - if (lines.size() == 1) { - if (!remotes.contains(lines.get(0))) { - die("The given remote to push to, " + lines.get(0) + ", does not exist"); + sourceURI = sourceParentURI; + sourceName = sourceParentName; + + // Find pull source as given by Git's 'upstream' remote + if (remotes.contains("upstream")) { + sourceName = "upstream"; + var sourceUpstreamURI = Remote.toURI(repo.pullPath("upstream")); + if (!equalsCanonicalized(sourceUpstreamURI, sourceParentURI)) { + if (arguments.contains("force")) { + sourceURI = sourceUpstreamURI; + logVerbose("Replacing Git Forge parent with " + sourceUpstreamURI + " from 'upstream' remote"); } else { - to = lines.get(0); + System.err.println("error: git 'upstream' remote and the parent fork given by the Git Forge differ"); + System.err.println(" Git 'upstream' remote is " + sourceUpstreamURI); + System.err.println(" Git Forge parent is " + sourceParentURI); + System.err.println(" Remove incorrect 'upstream' remote with 'git remote remove upstream'"); + System.err.println(" or run with --force to use 'upstream' remote anyway"); + System.exit(1); } - } else { - if (remotes.contains("fork")) { - to = "fork"; + } + } else { + // Repo is badly configured, fix it unless instructed not to + if (!arguments.contains("no-remote")) { + System.out.println("Setting 'upstream' remote to " + sourceParentURI); + if (!isDryRun) { + repo.addRemote("upstream", sourceParentURI.toString()); + } + } + } + + // Find pull source as given by command line options + if (sourceFromOptions != null) { + if (!equalsCanonicalized(sourceFromOptionsURI, sourceURI)) { + if (arguments.contains("force")) { + // Use the value from the option instead + sourceName = sourceFromOptions; + sourceURI = sourceFromOptionsURI; + logVerbose("Replacing source repo with " + sourceFromOptionsURI + " from command line options"); } else { - to = "origin"; + die("Git Forge parent and git sync '--from' option do not match"); } } } - var toPushPath = remotes.contains(to) ? - Remote.toURI(repo.pullPath(to)) : Remote.toURI(to); + if (sourceURI == null) { + System.err.println("error: could not find repository to sync from, please specify one with --from"); + System.err.println(" or add a remote named 'upstream'"); + System.exit(1); + } - var canonicalPushPath = Remote.toWebURI(Remote.canonicalize(toPushPath).toString()); - var canonicalPullPath = Remote.toWebURI(Remote.canonicalize(fromPullPath).toString()); - if (canonicalPushPath.equals(canonicalPullPath)) { - System.err.println("error: --from and --to refer to the same repository: " + canonicalPushPath.toString()); + if (equalsCanonicalized(targetURI, sourceURI)) { + System.err.println("error: --from and --to refer to the same repository: " + targetURI); System.exit(1); } + } + + private void setupCredentials() throws IOException { + var sourceScheme = sourceURI.getScheme(); + if (sourceScheme.equals("https") || sourceScheme.equals("http")) { + var token = System.getenv("GIT_TOKEN"); + var username = getOption("username"); + var credentials = GitCredentials.fill(sourceURI.getHost(), + sourceURI.getPath(), + username, + token, + sourceScheme); + if (credentials.password() != null && credentials.username() != null && token != null) { + sourceURI = URI.create(sourceScheme + "://" + credentials.username() + ":" + credentials.password() + "@" + sourceURI.getHost() + sourceURI.getPath()); + } + } - var toScheme = toPushPath.getScheme(); - if (toScheme.equals("https") || toScheme.equals("http")) { + var targetScheme = targetURI.getScheme(); + if (targetScheme.equals("https") || targetScheme.equals("http")) { var token = System.getenv("GIT_TOKEN"); - var username = getOption("username", arguments, repo); - var credentials = GitCredentials.fill(toPushPath.getHost(), - toPushPath.getPath(), - username, - token, - toScheme); + var username = getOption("username"); + var credentials = GitCredentials.fill(targetURI.getHost(), + targetURI.getPath(), + username, + token, + targetScheme); if (credentials.password() == null) { - die("error: no personal access token found, use git-credentials or the environment variable GIT_TOKEN"); + die("no personal access token found, use git-credentials or the environment variable GIT_TOKEN"); } if (credentials.username() == null) { - die("error: no username for " + toPushPath.getHost() + " found, use git-credentials or the flag --username"); + die("no username for " + targetURI.getHost() + " found, use git-credentials or the flag --username"); } if (token != null) { - toPushPath = URI.create(toScheme + "://" + credentials.username() + ":" + credentials.password() + "@" + - toPushPath.getHost() + toPushPath.getPath()); + targetURI = URI.create(targetScheme + "://" + credentials.username() + ":" + credentials.password() + "@" + + targetURI.getHost() + targetURI.getPath()); } else { GitCredentials.approve(credentials); } } + } + + public void sync() throws IOException, InterruptedException { + if (arguments.contains("version")) { + System.out.println("git-sync version: " + Version.fromManifest().orElse("unknown")); + System.exit(0); + } + + if (arguments.contains("verbose") || arguments.contains("debug")) { + var level = arguments.contains("debug") ? Level.FINER : Level.FINE; + Logging.setup(level); + } + + if (isDryRun) { + System.out.println("Running in dry-run mode. No actual changes will be performed"); + } + + HttpProxy.setup(); + + // Setup source (from, upstream) and target (to, origin) repo names and URIs + setupTargetAndSource(); + System.out.println("Will sync changes from " + sourceURI + " to " + targetURI); + + // Assure we have proper credentials for pull and push operations + setupCredentials(); var branches = new HashSet(); if (arguments.contains("branches")) { @@ -270,42 +338,35 @@ static void sync(Repository repo, String[] args) throws IOException, Interrupted } } - var remoteBranches = repo.remoteBranches(from); + var remoteBranches = repo.remoteBranches(sourceName); for (var branch : remoteBranches) { var name = branch.name(); if (!branches.isEmpty() && !branches.contains(name)) { - if (arguments.contains("verbose") || arguments.contains("debug")) { - System.out.println("Skipping branch " + name); - } + logVerbose("Skipping branch " + name); continue; } if (ignore.matcher(name).matches()) { - if (arguments.contains("verbose") || arguments.contains("debug")) { - System.out.println("Skipping branch " + name); - } + logVerbose("Skipping branch " + name); continue; } - System.out.print("Syncing " + from + "/" + name + " to " + to + "/" + name + "... "); - System.out.flush(); - var fetchHead = repo.fetch(fromPullPath, branch.name()); - repo.push(fetchHead, toPushPath, name); - System.out.println("done"); + + System.out.println("Syncing " + sourceName + "/" + name + " to " + targetName + "/" + name + "... "); + syncBranch(name); + System.out.println("Done syncing"); } var shouldPull = arguments.contains("pull"); if (!shouldPull) { var lines = repo.config("sync.pull"); - shouldPull = lines.size() == 1 && lines.get(0).toLowerCase().equals("true"); + shouldPull = lines.size() == 1 && lines.get(0).equalsIgnoreCase("true"); } if (shouldPull) { var currentBranch = repo.currentBranch(); if (currentBranch.isPresent()) { var upstreamBranch = repo.upstreamFor(currentBranch.get()); if (upstreamBranch.isPresent()) { - int err = pull(repo); - if (err != 0) { - System.exit(err); - } + logVerbose("Pulling from " + repo); + pull(); } } } @@ -313,17 +374,18 @@ static void sync(Repository repo, String[] args) throws IOException, Interrupted var shouldFastForward = arguments.contains("fast-forward"); if (!shouldFastForward) { var lines = repo.config("sync.fast-forward"); - shouldFastForward = lines.size() == 1 && lines.get(0).toLowerCase().equals("true"); + shouldFastForward = lines.size() == 1 && lines.get(0).equalsIgnoreCase("true"); } if (shouldFastForward) { - if (!remotes.contains(to)) { - die("error: --fast-forward can only be used when --to is the name of a remote"); + if (!remotes.contains(targetName)) { + die("--fast-forward can only be used when --to is the name of a remote"); } - repo.fetchRemote(to); + logVerbose("Fetching from remote " + targetName); + fetchTarget(); var remoteBranchNames = new HashSet(); for (var branch : remoteBranches) { - remoteBranchNames.add(to + "/" + branch.name()); + remoteBranchNames.add(targetName + "/" + branch.name()); } var currentBranch = repo.currentBranch(); @@ -336,11 +398,12 @@ static void sync(Repository repo, String[] args) throws IOException, Interrupted if (localHash.isPresent() && upstreamHash.isPresent() && !upstreamHash.equals(localHash) && repo.isAncestor(localHash.get(), upstreamHash.get())) { - var err = currentBranch.isPresent() && branch.equals(currentBranch.get()) ? - mergeFastForward(repo, upstreamBranch.get()) : - moveBranch(repo, branch, upstreamHash.get()); - if (err != 0) { - System.exit(1); + if (currentBranch.isPresent() && branch.equals(currentBranch.get())) { + logVerbose("Fast-forwarding current branch"); + mergeFastForward(upstreamBranch.get()); + } else { + logVerbose("Fast-forwarding branch " + upstreamBranch.get()); + moveBranch(branch, upstreamHash.get()); } } } @@ -348,12 +411,102 @@ static void sync(Repository repo, String[] args) throws IOException, Interrupted } } + private static IOException die(String message) { + System.err.println("error: " + message); + System.exit(1); + return new IOException("will never reach here"); + } + + private static boolean equalsCanonicalized(URI a, URI b) throws IOException { + if (a == null || b == null) { + if (a == null && b == null) { + return true; + } + return false; + } + + var canonicalA = Remote.toWebURI(Remote.canonicalize(a).toString()); + var canonicalB = Remote.toWebURI(Remote.canonicalize(b).toString()); + return canonicalA.equals(canonicalB); + } + + private static Arguments parseArguments(String[] args) { + var flags = List.of( + Option.shortcut("") + .fullname("from") + .describe("REMOTE") + .helptext("Fetch changes from this remote") + .optional(), + Option.shortcut("") + .fullname("to") + .describe("REMOTE") + .helptext("Push changes to this remote") + .optional(), + Option.shortcut("") + .fullname("branches") + .describe("BRANCHES") + .helptext("Comma separated list of branches to sync") + .optional(), + Option.shortcut("") + .fullname("ignore") + .describe("PATTERN") + .helptext("Regular expression of branches to ignore") + .optional(), + Option.shortcut("u") + .fullname("username") + .describe("NAME") + .helptext("Username on forge") + .optional(), + Switch.shortcut("") + .fullname("pull") + .helptext("Pull current branch from origin after successful sync") + .optional(), + Switch.shortcut("ff") + .fullname("fast-forward") + .helptext("Fast forward all local branches where possible") + .optional(), + Switch.shortcut("") + .fullname("no-remote") + .helptext("Do not add an additional git remote") + .optional(), + Switch.shortcut("n") + .fullname("dry-run") + .helptext("Only simulate behavior, do no actual changes") + .optional(), + Switch.shortcut("") + .fullname("force") + .helptext("Force syncing even between unrelated repos (beware!)") + .optional(), + Switch.shortcut("") + .fullname("verbose") + .helptext("Turn on verbose output") + .optional(), + Switch.shortcut("") + .fullname("debug") + .helptext("Turn on debugging output") + .optional(), + Switch.shortcut("v") + .fullname("version") + .helptext("Print the version of this tool") + .optional() + ); + + var parser = new ArgumentParser("git sync", flags); + return parser.parse(args); + } + + public static void sync(Repository repo, String[] args) throws IOException, InterruptedException { + GitSync commandExecutor = new GitSync(repo, parseArguments(args)); + commandExecutor.sync(); + } + public static void main(String[] args) throws IOException, InterruptedException { var cwd = Paths.get("").toAbsolutePath(); var repo = Repository.get(cwd).orElseThrow(() -> - die("error: no repository found at " + cwd.toString()) + die("no repository found at " + cwd) ); - sync(repo, args); + GitSync commandExecutor = new GitSync(repo, parseArguments(args)); + commandExecutor.sync(); } }