diff a/cli/build.gradle b/cli/build.gradle
--- a/cli/build.gradle
+++ b/cli/build.gradle
@@ -60,11 +60,12 @@
         'git-pr': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitPr',
         'git-token': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitToken',
         'git-info': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitInfo',
         'git-translate': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitTranslate',
         'git-skara': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitSkara',
-        'hg-openjdk-import': 'org.openjdk.skara.cli/org.openjdk.skara.cli.HgOpenJDKImport'
+        'hg-openjdk-import': 'org.openjdk.skara.cli/org.openjdk.skara.cli.HgOpenJDKImport',
+        'git-sync': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitSync'
     ]
 
     ext.modules = ['jdk.crypto.ec']
 
     windows {
diff a/cli/src/main/java/org/openjdk/skara/cli/GitFork.java b/cli/src/main/java/org/openjdk/skara/cli/GitFork.java
--- a/cli/src/main/java/org/openjdk/skara/cli/GitFork.java
+++ b/cli/src/main/java/org/openjdk/skara/cli/GitFork.java
@@ -27,11 +27,11 @@
 import org.openjdk.skara.vcs.Repository;
 import org.openjdk.skara.proxy.HttpProxy;
 
 import java.io.IOException;
 import java.net.URI;
-import java.nio.file.Path;
+import java.nio.file.*;
 import java.util.List;
 import java.util.function.Supplier;
 import java.util.logging.Level;
 
 public class GitFork {
@@ -173,10 +173,18 @@
             var upstreamUrl = uri.toString();
             if (isMercurial) {
                 upstreamUrl = "git+" + upstreamUrl;
             }
             repo.addRemote("upstream", upstreamUrl);
+            var gitConfig = repo.root().resolve(".git").resolve("config");
+            if (!isMercurial && Files.exists(gitConfig)) {
+                var lines = List.of(
+                    "[sync]",
+                    "        remote = upstream"
+                );
+                Files.write(gitConfig, lines, StandardOpenOption.WRITE, StandardOpenOption.APPEND);
+            }
             System.out.println("done");
         } else {
             System.out.println(webUrl);
         }
     }
diff a/cli/src/main/java/org/openjdk/skara/cli/GitPr.java b/cli/src/main/java/org/openjdk/skara/cli/GitPr.java
--- a/cli/src/main/java/org/openjdk/skara/cli/GitPr.java
+++ b/cli/src/main/java/org/openjdk/skara/cli/GitPr.java
@@ -210,50 +210,10 @@
         var pb = new ProcessBuilder("git", "apply", "--no-commit", patch.toString());
         pb.inheritIO();
         await(pb.start());
     }
 
-    private static URI toURI(String remotePath) throws IOException {
-        if (remotePath.startsWith("git+")) {
-            remotePath = remotePath.substring("git+".length());
-        }
-        if (remotePath.startsWith("http")) {
-            return URI.create(remotePath);
-        } else {
-            if (remotePath.startsWith("ssh://")) {
-                remotePath = remotePath.substring("ssh://".length()).replaceFirst("/", ":");
-            }
-            var indexOfColon = remotePath.indexOf(':');
-            var indexOfSlash = remotePath.indexOf('/');
-            if (indexOfColon != -1) {
-                if (indexOfSlash == -1 || indexOfColon < indexOfSlash) {
-                    var path = remotePath.contains("@") ? remotePath.split("@")[1] : remotePath;
-                    var name = path.split(":")[0];
-
-                    // Could be a Host in the ~/.ssh/config file
-                    var sshConfig = Path.of(System.getProperty("user.home"), ".ssh", "config");
-                    if (Files.exists(sshConfig)) {
-                        for (var host : SSHConfig.parse(sshConfig).hosts()) {
-                            if (host.name().equals(name)) {
-                                var hostName = host.hostName();
-                                if (hostName != null) {
-                                    return URI.create("https://" + hostName + "/" + path.split(":")[1]);
-                                }
-                            }
-                        }
-                    }
-
-                    // Otherwise is must be a domain
-                    return URI.create("https://" + path.replace(":", "/"));
-                }
-            }
-        }
-
-        exit("error: cannot find remote repository for " + remotePath);
-        return null; // will never reach here
-    }
-
     private static int longest(List<String> strings) {
         return strings.stream().mapToInt(String::length).max().orElse(0);
     }
 
     public static void main(String[] args) throws IOException, InterruptedException {
@@ -345,11 +305,11 @@
         var repo = Repository.get(cwd).orElseThrow(() -> new IOException("no git repository found at " + cwd.toString()));
         var remote = arguments.get("remote").orString(isMercurial ? "default" : "origin");
         var remotePullPath = repo.pullPath(remote);
         var username = arguments.contains("username") ? arguments.get("username").asString() : null;
         var token = isMercurial ? System.getenv("HG_TOKEN") :  System.getenv("GIT_TOKEN");
-        var uri = toURI(remotePullPath);
+        var uri = Remote.toWebURI(remotePullPath);
         var credentials = GitCredentials.fill(uri.getHost(), uri.getPath(), username, token, uri.getScheme());
         var host = Host.from(uri, new PersonalAccessToken(credentials.username(), credentials.password()));
 
         var action = arguments.at(0).asString();
         if (action.equals("create")) {
diff a/cli/src/main/java/org/openjdk/skara/cli/GitSkara.java b/cli/src/main/java/org/openjdk/skara/cli/GitSkara.java
--- a/cli/src/main/java/org/openjdk/skara/cli/GitSkara.java
+++ b/cli/src/main/java/org/openjdk/skara/cli/GitSkara.java
@@ -114,10 +114,11 @@
         commands.put("fork", GitFork::main);
         commands.put("pr", GitPr::main);
         commands.put("token", GitToken::main);
         commands.put("info", GitInfo::main);
         commands.put("translate", GitTranslate::main);
+        commands.put("sync", GitSync::main);
         commands.put("update", GitSkara::update);
         commands.put("help", GitSkara::usage);
 
         var isEmpty = args.length == 0;
         var command = isEmpty ? "help" : args[0];
diff a/cli/src/main/java/org/openjdk/skara/cli/GitSync.java b/cli/src/main/java/org/openjdk/skara/cli/GitSync.java
--- /dev/null
+++ b/cli/src/main/java/org/openjdk/skara/cli/GitSync.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2019, 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
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+package org.openjdk.skara.cli;
+
+import org.openjdk.skara.args.*;
+import org.openjdk.skara.vcs.*;
+
+import java.io.*;
+import java.net.URI;
+import java.nio.file.*;
+import java.util.*;
+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 static int fetch() throws IOException, InterruptedException {
+        var pb = new ProcessBuilder("git", "fetch");
+        pb.inheritIO();
+        return pb.start().waitFor();
+    }
+
+    private static int pull() throws IOException, InterruptedException {
+        var pb = new ProcessBuilder("git", "pull");
+        pb.inheritIO();
+        return pb.start().waitFor();
+    }
+
+    public static void main(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(),
+            Switch.shortcut("")
+                  .fullname("pull")
+                  .helptext("Pull current branch from origin after successful sync")
+                  .optional(),
+            Switch.shortcut("")
+                  .fullname("fetch")
+                  .helptext("Fetch current branch from origin after successful sync")
+                  .optional(),
+            Switch.shortcut("m")
+                  .fullname("mercurial")
+                  .helptext("Force use of mercurial")
+                  .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);
+        var arguments = parser.parse(args);
+
+        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);
+        }
+
+        var cwd = Paths.get("").toAbsolutePath();
+        var repo = Repository.get(cwd).orElseThrow(() ->
+                die("error: no repository found at " + cwd.toString())
+        );
+
+        var remotes = repo.remotes();
+
+        String upstream = null;
+        if (arguments.contains("from")) {
+            upstream = arguments.get("from").asString();
+        } else {
+            var lines = repo.config("sync.from");
+            if (lines.size() == 1 && remotes.contains(lines.get(0))) {
+                upstream = lines.get(0);
+            } else {
+                die("No remote provided to fetch from, please set the --from flag");
+            }
+        }
+        var upstreamPullPath = remotes.contains(upstream) ?
+            Remote.toURI(repo.pullPath(upstream)) : URI.create(upstream);
+
+        String origin = null;
+        if (arguments.contains("to")) {
+            origin = 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");
+                } else {
+                    origin = lines.get(0);
+                }
+            } else {
+                origin = "origin";
+            }
+        }
+        var originPushPath = Remote.toURI(repo.pushPath(origin));
+
+        var branches = new HashSet<String>();
+        if (arguments.contains("branches")) {
+            var requested = arguments.get("branches").asString().split(",");
+            for (var branch : requested) {
+                branches.add(branch.trim());
+            }
+        }
+
+        for (var branch : repo.remoteBranches(upstream)) {
+            var name = branch.name();
+            if (!branches.isEmpty() && !branches.contains(name)) {
+                System.out.println("Skipping branch " + name);
+                continue;
+            }
+            System.out.print("Syncing " + upstream + "/" + name + " to " + origin + "/" + name + "... ");
+            System.out.flush();
+            var fetchHead = repo.fetch(upstreamPullPath, branch.hash().hex());
+            repo.push(fetchHead, originPushPath, name);
+            System.out.println("done");
+        }
+
+        if (arguments.contains("fetch")) {
+            int err = fetch();
+            if (err != 0) {
+                System.exit(err);
+            }
+        }
+
+        if (arguments.contains("pull")) {
+            int err = pull();
+            if (err != 0) {
+                System.exit(err);
+            }
+        }
+    }
+}
diff a/cli/src/main/java/org/openjdk/skara/cli/Remote.java b/cli/src/main/java/org/openjdk/skara/cli/Remote.java
--- /dev/null
+++ b/cli/src/main/java/org/openjdk/skara/cli/Remote.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2019, 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
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+package org.openjdk.skara.cli;
+
+import org.openjdk.skara.ssh.SSHConfig;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Files;
+
+class Remote {
+    public static URI toWebURI(String remotePath) throws IOException {
+        if (remotePath.startsWith("git+")) {
+            remotePath = remotePath.substring("git+".length());
+        }
+        if (remotePath.startsWith("http")) {
+            return URI.create(remotePath);
+        } else {
+            if (remotePath.startsWith("ssh://")) {
+                remotePath = remotePath.substring("ssh://".length()).replaceFirst("/", ":");
+            }
+            var indexOfColon = remotePath.indexOf(':');
+            var indexOfSlash = remotePath.indexOf('/');
+            if (indexOfColon != -1) {
+                if (indexOfSlash == -1 || indexOfColon < indexOfSlash) {
+                    var path = remotePath.contains("@") ? remotePath.split("@")[1] : remotePath;
+                    var name = path.split(":")[0];
+
+                    // Could be a Host in the ~/.ssh/config file
+                    var sshConfig = Path.of(System.getProperty("user.home"), ".ssh", "config");
+                    if (Files.exists(sshConfig)) {
+                        for (var host : SSHConfig.parse(sshConfig).hosts()) {
+                            if (host.name().equals(name)) {
+                                var hostName = host.hostName();
+                                if (hostName != null) {
+                                    return URI.create("https://" + hostName + "/" + path.split(":")[1]);
+                                }
+                            }
+                        }
+                    }
+
+                    // Otherwise is must be a domain
+                    return URI.create("https://" + path.replace(":", "/"));
+                }
+            }
+        }
+
+        throw new IOException("error: cannot find remote repository for " + remotePath);
+    }
+
+    public static URI toURI(String remotePath) throws IOException {
+        if (remotePath.startsWith("git+")) {
+            remotePath = remotePath.substring("git+".length());
+        }
+        if (remotePath.startsWith("http://") ||
+            remotePath.startsWith("https://") ||
+            remotePath.startsWith("ssh://") ||
+            remotePath.startsWith("file://") ||
+            remotePath.startsWith("git://")) {
+            return URI.create(remotePath);
+        }
+
+        var indexOfColon = remotePath.indexOf(':');
+        var indexOfSlash = remotePath.indexOf('/');
+        if (indexOfColon != -1) {
+            if (indexOfSlash == -1 || indexOfColon < indexOfSlash) {
+                var path = remotePath.contains("@") ? remotePath.split("@")[1] : remotePath;
+                var name = path.split(":")[0];
+
+                // Could be a Host in the ~/.ssh/config file
+                var sshConfig = Path.of(System.getProperty("user.home"), ".ssh", "config");
+                if (Files.exists(sshConfig)) {
+                    for (var host : SSHConfig.parse(sshConfig).hosts()) {
+                        if (host.name().equals(name)) {
+                            var hostName = host.hostName();
+                            if (hostName != null) {
+                                var username = host.user();
+                                if (username == null) {
+                                    username = remotePath.contains("@") ? remotePath.split("@")[0] : null;
+                                }
+                                var userPrefix = username == null ? "" : username + "@";
+                                return URI.create("ssh://" + userPrefix + hostName + "/" + path.split(":")[1]);
+                            }
+                        }
+                    }
+                }
+
+                // Otherwise is must be a domain
+                var userPrefix = remotePath.contains("@") ? remotePath.split("@")[0] + "@" : "";
+                return URI.create("ssh://" + userPrefix + path.replace(":", "/"));
+            }
+        }
+
+        throw new IOException("error: cannot construct proper URI for " + remotePath);
+    }
+}
diff a/jcheck/src/test/java/org/openjdk/skara/jcheck/TestRepository.java b/jcheck/src/test/java/org/openjdk/skara/jcheck/TestRepository.java
--- a/jcheck/src/test/java/org/openjdk/skara/jcheck/TestRepository.java
+++ b/jcheck/src/test/java/org/openjdk/skara/jcheck/TestRepository.java
@@ -222,6 +222,14 @@
     }
 
     public boolean contains(Branch b, Hash h) throws IOException {
         return false;
     }
+
+    public List<Reference> remoteBranches(String remote) throws IOException {
+        return null;
+    }
+
+    public List<String> remotes() throws IOException {
+        return null;
+    }
 }
diff a/skara.gitconfig b/skara.gitconfig
--- a/skara.gitconfig
+++ b/skara.gitconfig
@@ -29,5 +29,6 @@
         fork = ! git skara fork
         pr = ! git skara pr
         token = ! git skara token
         info = ! git skara info
         translate = ! git skara translate
+        sync = ! git skara sync
diff a/vcs/src/main/java/org/openjdk/skara/vcs/ReadOnlyRepository.java b/vcs/src/main/java/org/openjdk/skara/vcs/ReadOnlyRepository.java
--- a/vcs/src/main/java/org/openjdk/skara/vcs/ReadOnlyRepository.java
+++ b/vcs/src/main/java/org/openjdk/skara/vcs/ReadOnlyRepository.java
@@ -82,10 +82,12 @@
     Repository copyTo(Path destination) throws IOException;
     String pullPath(String remote) throws IOException;
     String pushPath(String remote) throws IOException;
     boolean isValidRevisionRange(String expression) throws IOException;
     Optional<String> upstreamFor(Branch branch) throws IOException;
+    List<Reference> remoteBranches(String remote) throws IOException;
+    List<String> remotes() throws IOException;
 
     static Optional<ReadOnlyRepository> get(Path p) throws IOException {
         return Repository.get(p).map(r -> r);
     }
 
diff a/vcs/src/main/java/org/openjdk/skara/vcs/Reference.java b/vcs/src/main/java/org/openjdk/skara/vcs/Reference.java
--- /dev/null
+++ b/vcs/src/main/java/org/openjdk/skara/vcs/Reference.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2019, 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
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+package org.openjdk.skara.vcs;
+
+import java.util.Objects;
+
+public class Reference {
+    private final String name;
+    private final Hash hash;
+
+    public Reference(String name, Hash hash) {
+        this.name = name;
+        this.hash = hash;
+    }
+
+    public String name() {
+        return name;
+    }
+
+    public Hash hash() {
+        return hash;
+    }
+
+    @Override
+    public String toString() {
+        return name + ": " + hash.hex();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof Reference)) {
+            return false;
+        }
+
+        var r = (Reference) o;
+        return Objects.equals(name, r.name) && Objects.equals(hash, r.hash);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(name, hash);
+    }
+}
diff a/vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java b/vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java
--- a/vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java
+++ b/vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java
@@ -1063,6 +1063,30 @@
             }
         }
 
         return false;
     }
+
+    @Override
+    public List<Reference> remoteBranches(String remote) throws IOException {
+        var refs = new ArrayList<Reference>();
+        try (var p = capture("git", "ls-remote", "--heads", "--refs", remote)) {
+            for (var line : await(p).stdout()) {
+                var parts = line.split("\t");
+                var name = parts[1].replace("refs/heads/", "");
+                refs.add(new Reference(name, new Hash(parts[0])));
+            }
+        }
+        return refs;
+    }
+
+    @Override
+    public List<String> remotes() throws IOException {
+        var remotes = new ArrayList<String>();
+        try (var p = capture("git", "remote")) {
+            for (var line : await(p).stdout()) {
+                remotes.add(line);
+            }
+        }
+        return remotes;
+    }
 }
diff a/vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java b/vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java
--- a/vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java
+++ b/vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java
@@ -1090,6 +1090,40 @@
             }
             var line = res.stdout().get(0);
             return line.equals(b.name());
         }
     }
+
+    @Override
+    public List<Reference> remoteBranches(String remote) throws IOException {
+        var refs = new ArrayList<Reference>();
+
+        var ext = Files.createTempFile("ext", ".py");
+        copyResource(EXT_PY, ext);
+
+        try (var p = capture("hg", "--config", "extensions.ls-remote=" + ext, "ls-remote", remote)) {
+            var res = await(p);
+            for (var line : res.stdout()) {
+                var parts = line.split("\t");
+                refs.add(new Reference(parts[1], new Hash(parts[0])));
+            }
+        }
+        return refs;
+    }
+
+    @Override
+    public List<String> remotes() throws IOException {
+        var remotes = new ArrayList<String>();
+        try (var p = capture("hg", "paths")) {
+            for (var line : await(p).stdout()) {
+                var parts = line.split(" = ");
+                var name = parts[0];
+                if (name.endsWith("-push") || name.endsWith(":push")) {
+                    continue;
+                } else {
+                    remotes.add(name);
+                }
+            }
+        }
+        return remotes;
+    }
 }
diff a/vcs/src/main/resources/ext.py b/vcs/src/main/resources/ext.py
--- a/vcs/src/main/resources/ext.py
+++ b/vcs/src/main/resources/ext.py
@@ -21,10 +21,12 @@
 
 import mercurial
 import mercurial.patch
 import mercurial.mdiff
 import mercurial.util
+import mercurial.hg
+import mercurial.node
 import difflib
 import sys
 
 # space separated version list
 testedwith = '4.9.2 5.0.2'
@@ -286,5 +288,14 @@
         else:
             write('100644 blob ')
         write(nullHash)
         write('\t')
         writeln(filename)
+
+@command('ls-remote', [],  'hg ls-remote PATH')
+def ls_remote(ui, repo, path, **opts):
+    peer = mercurial.hg.peer(ui or repo, opts, ui.expandpath(path))
+    for branch, heads in peer.branchmap().iteritems():
+        for head in heads:
+            write(mercurial.node.hex(head))
+            write("\t")
+            writeln(branch)
diff a/vcs/src/test/java/org/openjdk/skara/vcs/RepositoryTests.java b/vcs/src/test/java/org/openjdk/skara/vcs/RepositoryTests.java
--- a/vcs/src/test/java/org/openjdk/skara/vcs/RepositoryTests.java
+++ b/vcs/src/test/java/org/openjdk/skara/vcs/RepositoryTests.java
@@ -1815,6 +1815,37 @@
 
             assertEquals(initial, repo.head());
             assertEquals(1, repo.commits().asList().size());
         }
     }
+
+    @ParameterizedTest
+    @EnumSource(VCS.class)
+    void testRemotes(VCS vcs) throws IOException {
+        try (var dir = new TemporaryDirectory()) {
+            var repo = Repository.init(dir.path(), vcs);
+            assertEquals(List.of(), repo.remotes());
+            repo.addRemote("foobar", "https://foo/bar");
+            assertEquals(List.of("foobar"), repo.remotes());
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(VCS.class)
+    void testRemoteBranches(VCS vcs) throws IOException {
+        try (var dir = new TemporaryDirectory()) {
+            var upstream = Repository.init(dir.path().resolve("upstream"), vcs);
+            var readme = upstream.root().resolve("README");
+            Files.writeString(readme, "Hello\n");
+            upstream.add(readme);
+            var head = upstream.commit("Added README", "duke", "duke@openjdk.org");
+
+            var fork = Repository.init(dir.path().resolve("fork"), vcs);
+            fork.addRemote("upstream", "file://" + upstream.root());
+            var refs = fork.remoteBranches("upstream");
+            assertEquals(1, refs.size());
+            var ref = refs.get(0);
+            assertEquals(head, ref.hash());
+            assertEquals(upstream.defaultBranch().name(), ref.name());
+        }
+    }
 }
