diff --git a/bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBot.java b/bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBot.java
index 0b5a949d4..57f342114 100644
--- a/bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBot.java
+++ b/bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBot.java
@@ -49,19 +49,21 @@ class MirrorBot implements Bot, WorkItem {
     private final List<Pattern> branchPatterns;
     private final boolean includeTags;
     private final boolean onlyTags;
+    private final List<String> refspecs;
 
     MirrorBot(Path storage, HostedRepository from, HostedRepository to) {
-        this(storage, from, to, List.of(), true, false);
+        this(storage, from, to, List.of(), true, false, List.of());
     }
 
     MirrorBot(Path storage, HostedRepository from, HostedRepository to, List<Pattern> branchPatterns,
-              boolean includeTags, boolean onlyTags) {
+              boolean includeTags, boolean onlyTags, List<String> refspecs) {
         this.storage = storage;
         this.from = from;
         this.to = to;
         this.branchPatterns = branchPatterns;
         this.includeTags = includeTags;
         this.onlyTags = onlyTags;
+        this.refspecs = refspecs;
     }
 
     @Override
@@ -103,14 +105,14 @@ public Collection<WorkItem> run(Path scratchPath) {
             }
 
             log.info("Pulling " + from.name());
-            repo.fetchAll(from.authenticatedUrl(), includeTags || onlyTags);
+            repo.fetchAll(from.authenticatedUrl(), includeTags || onlyTags || !refspecs.isEmpty());
             if (onlyTags) {
                 log.info("Pushing tags to " + to.name());
                 repo.pushTags(to.authenticatedUrl(), true);
             } else if (branchPatterns.isEmpty() && includeTags) {
                 log.info("Pushing tags and branches to " + to.name());
                 repo.pushAll(to.authenticatedUrl(), true);
-            } else {
+            } else if (!branchPatterns.isEmpty()) {
                 for (var branch : repo.branches()) {
                     if (branchPatterns.stream().anyMatch(p -> p.matcher(branch.name()).matches())) {
                         var hash = repo.resolve(branch);
@@ -123,6 +125,11 @@ public Collection<WorkItem> run(Path scratchPath) {
                         }
                     }
                 }
+            } else if (!refspecs.isEmpty()) {
+                for (var refspec : refspecs) {
+                    log.info("Pushing using refspec " + refspec + " to " + to.name());
+                    repo.push(refspec, to.authenticatedUrl());
+                }
             }
         } catch (IOException e) {
             throw new UncheckedIOException(e);
@@ -133,22 +140,26 @@ public Collection<WorkItem> run(Path scratchPath) {
     @Override
     public String toString() {
         var name = "MirrorBot@" + from.name() + "->" + to.name();
-        if (branchPatterns.isEmpty()) {
+        if (!refspecs.isEmpty()) {
+            name += " (" + String.join(",", refspecs) + ")";
+        } else {
+            if (branchPatterns.isEmpty()) {
+                if (onlyTags) {
+                    name += " ()";
+                } else {
+                    name += " (*)";
+                }
+            } else {
+                var branchPatterns = this.branchPatterns.stream().map(Pattern::toString).collect(Collectors.toList());
+                name += " (" + String.join(",", branchPatterns) + ")";
+            }
             if (onlyTags) {
-                name += " ()";
+                name += " [tags only]";
+            } else if (includeTags) {
+                name += " [tags included]";
             } else {
-                name += " (*)";
+                name += " [tags excluded]";
             }
-        } else {
-            var branchPatterns = this.branchPatterns.stream().map(Pattern::toString).collect(Collectors.toList());
-            name += " (" + String.join(",", branchPatterns) + ")";
-        }
-        if (onlyTags) {
-            name += " [tags only]";
-        } else if (includeTags) {
-            name += " [tags included]";
-        } else {
-            name += " [tags excluded]";
         }
         return name;
     }
@@ -184,4 +195,8 @@ public boolean isIncludeTags() {
     public boolean isOnlyTags() {
         return onlyTags;
     }
+
+    public List<String> getRefspecs() {
+        return refspecs;
+    }
 }
diff --git a/bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBotFactory.java b/bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBotFactory.java
index 99eddd6e9..7e1c94e24 100644
--- a/bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBotFactory.java
+++ b/bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBotFactory.java
@@ -60,8 +60,25 @@ public List<Bot> create(BotConfiguration configuration) {
             var toName = repo.get("to").asString();
             var toRepo = configuration.repository(toName);
 
+            List<String> refspecs;
+            if (repo.contains("refspecs")) {
+                var refspecsElement = repo.get("refspecs");
+                if (refspecsElement.isArray()) {
+                    refspecs = refspecsElement.asArray().stream()
+                            .map(JSONValue::asString)
+                            .toList();
+                } else {
+                    refspecs = List.of(refspecsElement.asString());
+                }
+            } else {
+                refspecs = List.of();
+            }
+
             List<Pattern> branchPatterns;
             if (repo.contains("branches")) {
+                if (!refspecs.isEmpty()) {
+                    throw new IllegalStateException("Cannot combine refspecs and branches");
+                }
                 // Accept both an array of regex patterns as well as a single comma separated
                 // string for backwards compatibility
                 var branchesElement = repo.get("branches");
@@ -79,7 +96,7 @@ public List<Bot> create(BotConfiguration configuration) {
                 branchPatterns = List.of();
             }
 
-            var includeTags = branchPatterns.isEmpty();
+            var includeTags = branchPatterns.isEmpty() && refspecs.isEmpty();
             var onlyTags = false;
             if (repo.contains("tags")) {
                 var tags = repo.get("tags").asString().toLowerCase().strip();
@@ -96,9 +113,12 @@ public List<Bot> create(BotConfiguration configuration) {
             if (onlyTags && !branchPatterns.isEmpty()) {
                 throw new IllegalStateException("Branches cannot be mirrored when only tags are mirrored");
             }
+            if ((onlyTags || includeTags) && !refspecs.isEmpty()) {
+                throw new IllegalStateException("Cannot combine refspecs and tags");
+            }
 
             log.info("Setting up mirroring from " + fromRepo.name() + " to " + toRepo.name());
-            bots.add(new MirrorBot(storage, fromRepo, toRepo, branchPatterns, includeTags, onlyTags));
+            bots.add(new MirrorBot(storage, fromRepo, toRepo, branchPatterns, includeTags, onlyTags, refspecs));
         }
         return bots;
     }
diff --git a/bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotFactoryTest.java b/bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotFactoryTest.java
index 8b0e07c8f..707ec0ac5 100644
--- a/bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotFactoryTest.java
+++ b/bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotFactoryTest.java
@@ -252,4 +252,109 @@ public void testCreateWithTags() {
                          mirrorBot6.getBranchPatterns().stream().map(Pattern::toString).toList());
         }
     }
+
+    @Test
+    public void testThrowsWithRefspecsAndTags() {
+        try (var tempFolder = new TemporaryDirectory()) {
+            String jsonString = """
+                    {
+                      "repositories": [
+                        {
+                          "from": "from1",
+                          "to": "to1",
+                          "refspecs": "refs/foo",
+                          "tags": "only"
+                        }
+                      ]
+                    }
+                    """;
+            var jsonConfig = JWCC.parse(jsonString).asObject();
+
+            var testBotFactory = TestBotFactory.newBuilder()
+                    .addHostedRepository("from1", new TestHostedRepository("from1"))
+                    .addHostedRepository("to1", new TestHostedRepository("to1"))
+                    .storagePath(tempFolder.path().resolve("storage"))
+                    .build();
+
+            assertThrows(IllegalStateException.class, () -> testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig));
+        }
+    }
+
+    @Test
+    public void testThrowsWithRefspecsAndBranches() {
+        try (var tempFolder = new TemporaryDirectory()) {
+            String jsonString = """
+                    {
+                      "repositories": [
+                        {
+                          "from": "from1",
+                          "to": "to1",
+                          "refspecs": "refs/foo",
+                          "branches": "master"
+                        }
+                      ]
+                    }
+                    """;
+            var jsonConfig = JWCC.parse(jsonString).asObject();
+
+            var testBotFactory = TestBotFactory.newBuilder()
+                    .addHostedRepository("from1", new TestHostedRepository("from1"))
+                    .addHostedRepository("to1", new TestHostedRepository("to1"))
+                    .storagePath(tempFolder.path().resolve("storage"))
+                    .build();
+
+            assertThrows(IllegalStateException.class, () -> testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig));
+        }
+    }
+
+    @Test
+    public void testCreateWithRefspecs() {
+        try (var tempFolder = new TemporaryDirectory()) {
+            String jsonString = """
+                    {
+                      "repositories": [
+                        {
+                          "from": "from1",
+                          "to": "to1",
+                          "refspecs": "refs/foo",
+                        },
+                        {
+                          "from": "from2",
+                          "to": "to2",
+                          "refspecs": [
+                            "refs/foo",
+                            "refs/bar"
+                          ]
+                        }
+                      ]
+                    }
+                    """;
+            var jsonConfig = JWCC.parse(jsonString).asObject();
+
+            var testBotFactory = TestBotFactory.newBuilder()
+                    .addHostedRepository("from1", new TestHostedRepository("from1"))
+                    .addHostedRepository("from2", new TestHostedRepository("from2"))
+                    .addHostedRepository("to1", new TestHostedRepository("to1"))
+                    .addHostedRepository("to2", new TestHostedRepository("to2"))
+                    .storagePath(tempFolder.path().resolve("storage"))
+                    .build();
+
+            var bots = testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig);
+            assertEquals(2, bots.size());
+
+            MirrorBot mirrorBot1 = (MirrorBot) bots.get(0);
+            assertEquals("MirrorBot@from1->to1 (refs/foo)", mirrorBot1.toString());
+            assertFalse(mirrorBot1.isIncludeTags());
+            assertFalse(mirrorBot1.isOnlyTags());
+            assertEquals(List.of(), mirrorBot1.getBranchPatterns());
+            assertEquals(List.of("refs/foo"), mirrorBot1.getRefspecs());
+
+            MirrorBot mirrorBot2 = (MirrorBot) bots.get(1);
+            assertEquals("MirrorBot@from2->to2 (refs/foo,refs/bar)", mirrorBot2.toString());
+            assertFalse(mirrorBot2.isIncludeTags());
+            assertFalse(mirrorBot2.isOnlyTags());
+            assertEquals(List.of(), mirrorBot2.getBranchPatterns());
+            assertEquals(List.of("refs/foo", "refs/bar"), mirrorBot2.getRefspecs());
+        }
+    }
 }
diff --git a/bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotTests.java b/bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotTests.java
index a0c396a98..b6248504f 100644
--- a/bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotTests.java
+++ b/bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotTests.java
@@ -216,7 +216,7 @@ void mirrorSingleBranchAndTags(TestInfo testInfo) throws IOException {
             assertEquals(0, toLocalRepo.tags().size());
 
             var storage = temp.path().resolve("storage");
-            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile("master")), true, false);
+            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile("master")), true, false, List.of());
             TestBotRunner.runPeriodicItems(bot);
 
             toCommits = toLocalRepo.commits().asList();
@@ -284,7 +284,7 @@ void mirrorSingleBranchNoTags(TestInfo testInfo) throws IOException {
             assertEquals(0, toLocalRepo.tags().size());
 
             var storage = temp.path().resolve("storage");
-            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile("master")), false, false);
+            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile("master")), false, false, List.of());
             TestBotRunner.runPeriodicItems(bot);
 
             toCommits = toLocalRepo.commits().asList();
@@ -393,7 +393,7 @@ void mirrorSelectedBranches(TestInfo testInfo) throws IOException {
             assertEquals(0, toCommits.size());
 
             var storage = temp.path().resolve("storage");
-            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile("master")), false, false);
+            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile("master")), false, false, List.of());
             TestBotRunner.runPeriodicItems(bot);
 
             toCommits = toLocalRepo.commits().asList();
@@ -441,7 +441,7 @@ void mirrorSelectedBranchPattern(TestInfo testInfo) throws IOException {
             assertEquals(0, toCommits.size());
 
             var storage = temp.path().resolve("storage");
-            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile("f.*")), false, false);
+            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile("f.*")), false, false, List.of());
             TestBotRunner.runPeriodicItems(bot);
 
             toCommits = toLocalRepo.commits().asList();
@@ -528,7 +528,7 @@ void mirrorOnlyTags(TestInfo testInfo) throws IOException {
             assertEquals(0, toLocalRepo.branches().size());
 
             var storage = temp.path().resolve("storage");
-            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(), true, true);
+            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(), true, true, List.of());
             TestBotRunner.runPeriodicItems(bot);
 
             toCommits = toLocalRepo.commits().asList();
@@ -568,4 +568,95 @@ void mirrorOnlyTags(TestInfo testInfo) throws IOException {
             assertEquals(fromLocalRepo.annotate(firstTag), toLocalRepo.annotate(firstTag), "First tag not correctly mirrored");
         }
     }
+
+    @Test
+    void mirrorRefspecs() throws IOException {
+        try (var temp = new TemporaryDirectory()) {
+            var host = TestHost.createNew(List.of(HostUser.create(0, "duke", "J. Duke")));
+
+            var fromDir = temp.path().resolve("from.git");
+            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);
+            var fromHostedRepo = new TestHostedRepository(host, "test", fromLocalRepo);
+
+            var toDir = temp.path().resolve("to.git");
+            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);
+            var gitConfig = toDir.resolve(".git").resolve("config");
+            Files.write(gitConfig, List.of("[receive]", "denyCurrentBranch = ignore"),
+                    StandardOpenOption.APPEND);
+            var toHostedRepo = new TestHostedRepository(host, "test-mirror", toLocalRepo);
+
+            var newFile = fromDir.resolve("this-file-cannot-exist.txt");
+            Files.writeString(newFile, "Hello world\n");
+            fromLocalRepo.add(newFile);
+            var newHash = fromLocalRepo.commit("An additional commit", "duke", "duke@openjdk.org");
+            var fromCommits = fromLocalRepo.commits().asList();
+            assertEquals(1, fromCommits.size());
+            assertEquals(newHash, fromCommits.get(0).hash());
+
+            var toCommits = toLocalRepo.commits().asList();
+            assertEquals(0, toCommits.size());
+
+            var storage = temp.path().resolve("storage");
+            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(), false, false,
+                    List.of("refs/heads/master:refs/heads/master"));
+            TestBotRunner.runPeriodicItems(bot);
+
+            toCommits = toLocalRepo.commits().asList();
+            assertEquals(1, toCommits.size());
+            assertEquals(newHash, toCommits.get(0).hash());
+        }
+    }
+
+    @Test
+    void mirrorMultipleRefspecs() throws IOException {
+        try (var temp = new TemporaryDirectory()) {
+            var host = TestHost.createNew(List.of(HostUser.create(0, "duke", "J. Duke")));
+
+            var fromDir = temp.path().resolve("from.git");
+            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);
+            var fromHostedRepo = new TestHostedRepository(host, "test", fromLocalRepo);
+
+            var toDir = temp.path().resolve("to.git");
+            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);
+            var gitConfig = toDir.resolve(".git").resolve("config");
+            Files.write(gitConfig, List.of("[receive]", "denyCurrentBranch = ignore"),
+                    StandardOpenOption.APPEND);
+            var toHostedRepo = new TestHostedRepository(host, "test-mirror", toLocalRepo);
+
+            var newFile = fromDir.resolve("this-file-cannot-exist.txt");
+            Files.writeString(newFile, "Hello world\n");
+            fromLocalRepo.add(newFile);
+            var first = fromLocalRepo.commit("An additional commit", "duke", "duke@openjdk.org");
+            var featureBranch = fromLocalRepo.branch(first, "feature");
+            fromLocalRepo.checkout(featureBranch, false);
+            assertEquals(Optional.of(featureBranch), fromLocalRepo.currentBranch());
+
+            Files.writeString(newFile, "Hello again\n", StandardOpenOption.APPEND);
+            fromLocalRepo.add(newFile);
+            var second = fromLocalRepo.commit("An additional commit", "duke", "duke@openjdk.org");
+
+            assertEquals(Optional.of(first), fromLocalRepo.resolve("master"));
+            assertEquals(Optional.of(second), fromLocalRepo.resolve("feature"));
+
+            fromLocalRepo.tag(first, "firstTag", "add first tag", "duke", "duk@openjdk.org");
+            fromLocalRepo.tag(second, "secondTag", "add second tag", "duke", "duk@openjdk.org");
+
+            var fromCommits = fromLocalRepo.commits().asList();
+            assertEquals(2, fromCommits.size());
+
+            var toCommits = toLocalRepo.commits().asList();
+            assertEquals(0, toCommits.size());
+
+            var storage = temp.path().resolve("storage");
+            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(), false, false,
+                    List.of("refs/heads/m*:refs/heads/m*", "refs/tags/s*:refs/tags/s*"));
+            TestBotRunner.runPeriodicItems(bot);
+
+            toCommits = toLocalRepo.commits().asList();
+            assertEquals(2, toCommits.size());
+            assertEquals(second, toCommits.get(0).hash());
+            assertEquals(List.of(new Branch("master")), toLocalRepo.branches());
+            assertEquals(List.of(new Tag("secondTag")), toLocalRepo.tags());
+        }
+    }
 }
diff --git a/vcs/src/main/java/org/openjdk/skara/vcs/Repository.java b/vcs/src/main/java/org/openjdk/skara/vcs/Repository.java
index d926cf45f..ca4d965b7 100644
--- a/vcs/src/main/java/org/openjdk/skara/vcs/Repository.java
+++ b/vcs/src/main/java/org/openjdk/skara/vcs/Repository.java
@@ -71,6 +71,7 @@ default void push(Hash hash, URI uri, String ref, boolean force) throws IOExcept
     void push(Hash hash, URI uri, String ref, boolean force, boolean includeTags) throws IOException;
     void push(Branch branch, String remote, boolean setUpstream) throws IOException;
     void push(Tag tag, URI uri, boolean force) throws IOException;
+    void push(String refspec, URI uri) throws IOException;
     void clean() throws IOException;
     void reset(Hash target, boolean hard) throws IOException;
     void revert(Hash parent) throws IOException;
diff --git a/vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java b/vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java
index f0d9bcf08..b187e3cde 100644
--- a/vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java
+++ b/vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java
@@ -649,6 +649,13 @@ public void push(Tag tag, URI uri, boolean force) throws IOException {
         }
     }
 
+    @Override
+    public void push(String refspec, URI uri) throws IOException {
+        try (var p = capture("git", "push", uri.toString(), refspec)) {
+            await(p);
+        }
+    }
+
     @Override
     public void push(Branch branch, String remote, boolean setUpstream) throws IOException {
         var cmd = new ArrayList<String>();
diff --git a/vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java b/vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java
index f61a2331a..d5a98a9a7 100644
--- a/vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java
+++ b/vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java
@@ -603,6 +603,11 @@ public void push(Tag tag, URI uri, boolean force) throws IOException {
         }
     }
 
+    @Override
+    public void push(String refspec, URI uri) throws IOException {
+        throw new RuntimeException("Refspec not supported with Mercurial");
+    }
+
     @Override
     public boolean isClean() throws IOException {
         try (var p = capture("hg", "status")) {