1 /*
   2  * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.
   3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   4  *
   5  * This code is free software; you can redistribute it and/or modify it
   6  * under the terms of the GNU General Public License version 2 only, as
   7  * published by the Free Software Foundation.
   8  *
   9  * This code is distributed in the hope that it will be useful, but WITHOUT
  10  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  11  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
  12  * version 2 for more details (a copy is included in the LICENSE file that
  13  * accompanied this code).
  14  *
  15  * You should have received a copy of the GNU General Public License version
  16  * 2 along with this work; if not, write to the Free Software Foundation,
  17  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
  18  *
  19  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  20  * or visit www.oracle.com if you need additional information or have any
  21  * questions.
  22  */
  23 package org.openjdk.skara.vcs.hg;
  24 
  25 import org.openjdk.skara.process.Process;
  26 import org.openjdk.skara.process.Execution;
  27 import org.openjdk.skara.vcs.*;
  28 import org.openjdk.skara.vcs.tools.*;
  29 
  30 import java.io.*;
  31 import java.nio.file.*;
  32 import java.time.*;
  33 import java.time.format.DateTimeFormatter;
  34 import java.util.*;
  35 import java.util.logging.Logger;
  36 import java.util.stream.*;
  37 import java.net.URI;
  38 
  39 public class HgRepository implements Repository {
  40     private static final String EXT_PY = "ext.py";
  41     private final Path dir;
  42     private final Logger log = Logger.getLogger("org.openjdk.skara.vcs.hg");
  43 
  44     private void copyResource(String name, Path p) throws IOException {
  45         Files.copy(this.getClass().getResourceAsStream("/" + name), p, StandardCopyOption.REPLACE_EXISTING);
  46     }
  47 
  48     private java.lang.Process start(String... cmd) throws IOException {
  49         return start(Arrays.asList(cmd));
  50     }
  51 
  52     private java.lang.Process start(List<String> cmd) throws IOException {
  53         log.fine("Executing " + String.join(" ", cmd));
  54         var pb = new ProcessBuilder(cmd);
  55         pb.directory(dir.toFile());
  56         pb.redirectError(ProcessBuilder.Redirect.DISCARD);
  57         pb.environment().put("HGRCPATH", "");
  58         pb.environment().put("HGPLAIN", "");
  59         return pb.start();
  60     }
  61 
  62     private static void stop(java.lang.Process p) throws IOException {
  63         if (p != null && p.isAlive()) {
  64             var stream = p.getInputStream();
  65             var read = 0;
  66             var buf = new byte[128];
  67             while (read != -1) {
  68                 read = stream.read(buf);
  69             }
  70             try {
  71                 p.waitFor();
  72             } catch (InterruptedException e) {
  73                 throw new IOException(e);
  74             }
  75         }
  76     }
  77 
  78     private Execution capture(List<String> cmd) {
  79         return capture(cmd.toArray(new String[0]));
  80     }
  81 
  82     private Execution capture(String... cmd) {
  83         return capture(dir, cmd);
  84     }
  85 
  86     private static Execution capture(Path cwd, List<String> cmd) {
  87         return capture(cwd, cmd.toArray(new String[0]));
  88     }
  89     private static Execution capture(Path cwd, String... cmd) {
  90         return Process.capture(cmd)
  91                       .environ("HGRCPATH", "")
  92                       .environ("HGPLAIN", "")
  93                       .workdir(cwd)
  94                       .execute();
  95     }
  96 
  97     private static Execution.Result await(Execution e) throws IOException {
  98         var result = e.await();
  99         if (result.status() != 0) {
 100             if (result.exception().isPresent()) {
 101                 throw new IOException("Unexpected exit code\n" + result, result.exception().get());
 102             } else {
 103                 throw new IOException("Unexpected exit code\n" + result);
 104             }
 105         }
 106         return result;
 107     }
 108 
 109     private static void await(java.lang.Process p) throws IOException {
 110         try {
 111             var res = p.waitFor();
 112             if (res != 0) {
 113                 throw new IOException("Unexpected exit code: " + res);
 114             }
 115         } catch (InterruptedException e) {
 116             throw new IOException(e);
 117         }
 118     }
 119 
 120     public HgRepository(Path dir) {
 121         this.dir = dir.toAbsolutePath();
 122     }
 123 
 124     @Override
 125     public List<Branch> branches() throws IOException {
 126         try (var p = capture("hg", "branches")) {
 127             return await(p).stdout()
 128                            .stream()
 129                            .map(line -> line.split("\\s")[0])
 130                            .map(Branch::new)
 131                            .collect(Collectors.toList());
 132         }
 133     }
 134 
 135     @Override
 136     public List<Tag> tags() throws IOException {
 137         try (var p = capture("hg", "tags")) {
 138             return await(p).stdout()
 139                            .stream()
 140                            .map(line -> line.split("\\s")[0])
 141                            .map(Tag::new)
 142                            .collect(Collectors.toList());
 143         }
 144     }
 145 
 146     @Override
 147     public Path root() throws IOException {
 148         try (var p = capture("hg", "root")) {
 149             var res = await(p);
 150             if (res.stdout().size() != 1) {
 151                 throw new IOException("Unexpected output\n" + res);
 152             }
 153             return Paths.get(res.stdout().get(0));
 154         }
 155     }
 156 
 157     private void checkout(String ref, boolean force) throws IOException {
 158         var cmd = new ArrayList<String>();
 159         cmd.addAll(List.of("hg", "update"));
 160         if (!force) {
 161             cmd.add("--check");
 162         }
 163         cmd.add(ref);
 164         try (var p = capture(cmd)) {
 165             await(p);
 166         }
 167     }
 168 
 169     @Override
 170     public void checkout(Hash h, boolean force) throws IOException {
 171         checkout(h.hex(), force);
 172     }
 173 
 174     @Override
 175     public void checkout(Branch b, boolean force) throws IOException {
 176         checkout(b.name(), force);
 177     }
 178 
 179     @Override
 180     public Optional<Hash> resolve(String ref) throws IOException {
 181         try (var p = capture("hg", "log", "--rev=" + ref, "--template={node}\n")) {
 182             var res = p.await();
 183             if (res.status() == 0 && res.stdout().size() == 1) {
 184                 return Optional.of(new Hash(res.stdout().get(0)));
 185             }
 186             return Optional.empty();
 187         }
 188     }
 189 
 190     @Override
 191     public Commits commits() throws IOException {
 192         return commits(null, -1, false);
 193     }
 194 
 195     @Override
 196     public Commits commits(boolean reverse) throws IOException {
 197         return commits(null, -1, reverse);
 198     }
 199 
 200     @Override
 201     public Commits commits(int n) throws IOException {
 202         return commits(null, n, false);
 203     }
 204 
 205     @Override
 206     public Commits commits(int n, boolean reverse) throws IOException {
 207         return commits(null, n, reverse);
 208     }
 209 
 210     @Override
 211     public Commits commits(String range) throws IOException {
 212         return commits(range, -1, false);
 213     }
 214 
 215     @Override
 216     public Commits commits(String range, int n) throws IOException {
 217         return commits(range, n, false);
 218     }
 219 
 220     @Override
 221     public Commits commits(String range, boolean reverse) throws IOException {
 222         return commits(range, -1, reverse);
 223     }
 224 
 225     @Override
 226     public Commits commits(String range, int n,  boolean reverse) throws IOException {
 227         var ext = Files.createTempFile("ext", ".py");
 228         copyResource(EXT_PY, ext);
 229         return new HgCommits(dir, range, ext, reverse, n);
 230     }
 231 
 232     @Override
 233     public Optional<Commit> lookup(Hash h) throws IOException {
 234         var commits = commits(h.hex()).asList();
 235         if (commits.size() != 1) {
 236             return Optional.empty();
 237         }
 238         return Optional.of(commits.get(0));
 239     }
 240 
 241     @Override
 242     public Optional<Commit> lookup(Branch b) throws IOException {
 243         var hash = resolve(b.name()).orElseThrow(() -> new IOException("Branch " + b.name() + " not found"));
 244         return lookup(hash);
 245     }
 246 
 247     @Override
 248     public Optional<Commit> lookup(Tag t) throws IOException {
 249         var hash = resolve(t.name()).orElseThrow(() -> new IOException("Tag " + t.name() + " not found"));
 250         return lookup(hash);
 251     }
 252 
 253     @Override
 254     public List<CommitMetadata> commitMetadata() throws IOException {
 255         var ext = Files.createTempFile("ext", ".py");
 256         copyResource(EXT_PY, ext);
 257 
 258         var p = start("hg", "--config", "extensions.dump=" + ext.toAbsolutePath().toString(), "metadata");
 259         var reader = new UnixStreamReader(p.getInputStream());
 260         var result = new ArrayList<CommitMetadata>();
 261 
 262         var line = reader.readLine();
 263         while (line != null) {
 264             result.add(HgCommitMetadata.read(reader));
 265             line = reader.readLine();
 266         }
 267 
 268         await(p);
 269         return result;
 270     }
 271 
 272     @Override
 273     public boolean isEmpty() throws IOException {
 274         var numBranches = branches().size();
 275         var numTags = tags().size();
 276 
 277         if (numBranches > 0 || numTags > 1) {
 278             return false;
 279         }
 280 
 281         var tip = resolve("tip");
 282         return tip.isEmpty() || tip.get().hex().equals("0".repeat(40));
 283     }
 284 
 285     @Override
 286     public boolean isHealthy() throws IOException {
 287         var root = root().toString();
 288         return !(Files.exists(Path.of(root, ".hg", "wlock")) ||
 289                  Files.exists(Path.of(root, ".hg", "store", "lock")));
 290     }
 291 
 292     @Override
 293     public void clean() throws IOException {
 294         try (var p = capture("hg", "merge", "--abort")) {
 295             p.await();
 296         }
 297 
 298         try (var p = capture("hg", "recover")) {
 299             p.await();
 300         }
 301 
 302         try (var p = capture("hg", "status", "--ignored", "--no-status")) {
 303             var root = root().toString();
 304             for (var filename : await(p).stdout()) {
 305                 Files.delete(Path.of(root, filename));
 306             }
 307         }
 308 
 309         try (var p = capture("hg", "status", "--unknown", "--no-status")) {
 310             var root = root().toString();
 311             for (var filename : await(p).stdout()) {
 312                 Files.delete(Path.of(root, filename));
 313             }
 314         }
 315 
 316         try (var p = capture("hg", "revert", "--no-backup", "--all")) {
 317             await(p);
 318         }
 319     }
 320 
 321     @Override
 322     public void reset(Hash target, boolean hard) throws IOException {
 323         throw new RuntimeException("Not implemented yet");
 324     }
 325 
 326     @Override
 327     public Repository reinitialize() throws IOException {
 328         Files.walk(dir)
 329              .map(Path::toFile)
 330              .sorted(Comparator.reverseOrder())
 331              .forEach(File::delete);
 332 
 333         return init();
 334     }
 335 
 336     @Override
 337     public Hash fetch(URI uri, String refspec) throws IOException {
 338         var oldHeads = new HashSet<Hash>(heads());
 339 
 340         var cmd = new ArrayList<String>();
 341         cmd.add("hg");
 342         cmd.add("pull");
 343         if (refspec != null) {
 344             cmd.add("--rev");
 345             cmd.add(refspec);
 346         }
 347         if (uri != null) {
 348             cmd.add(uri.toString());
 349         }
 350         try (var p = capture(cmd)) {
 351             await(p);
 352         }
 353 
 354         var newHeads = new HashSet<Hash>(heads());
 355         newHeads.removeAll(oldHeads);
 356 
 357         if (newHeads.size() > 1) {
 358             throw new IllegalStateException("fetching multiple heads is not supported");
 359         } else if (newHeads.size() == 0) {
 360             // no new head was fetched, return current head
 361             return head();
 362         }
 363         return newHeads.iterator().next();
 364     }
 365 
 366     @Override
 367     public void fetchAll() throws IOException {
 368         var pullPaths = new ArrayList<URI>();
 369         try (var p = capture("hg", "paths")) {
 370             var res = await(p);
 371             for (var line : res.stdout()) {
 372                 var parts = line.split("=");
 373                 var name = parts[0].trim();
 374                 var uri = parts[1].trim();
 375                 if (!name.endsWith("-push")) {
 376                     pullPaths.add(URI.create(uri));
 377                 }
 378             }
 379         }
 380 
 381         for (var uri : pullPaths) {
 382             fetch(uri, null);
 383         }
 384     }
 385 
 386     @Override
 387     public void delete(Branch b) throws IOException {
 388         throw new RuntimeException("Branches cannot be deleted in Mercurial");
 389     }
 390 
 391     @Override
 392     public Repository init() throws IOException {
 393         if (!Files.exists(dir)) {
 394             Files.createDirectories(dir);
 395         }
 396 
 397         try (var p = capture("hg", "init")) {
 398             await(p);
 399             return this;
 400         }
 401     }
 402 
 403     @Override
 404     public void pushAll(URI uri) throws IOException {
 405         try (var p = capture("hg", "push", "--new-branch", uri.toString())) {
 406             await(p);
 407         }
 408     }
 409 
 410     @Override
 411     public void push(Hash hash, URI uri, String ref, boolean force) throws IOException {
 412         var cmd = new ArrayList<>(List.of("hg", "push", "--rev=" + hash.hex()));
 413         if (force) {
 414             cmd.add("--force");
 415         }
 416         cmd.add(uri.toString() + "#" + ref);
 417         try (var p = capture(cmd)) {
 418             await(p);
 419         }
 420     }
 421 
 422     @Override
 423     public void push(Branch branch, String remote, boolean setUpstream) throws IOException {
 424         // ignore setUpstream, no such concept in Mercurial
 425         try (var p = capture("hg", "push", "--branch", branch.name(), remote)) {
 426             await(p);
 427         }
 428     }
 429 
 430     @Override
 431     public boolean isClean() throws IOException {
 432         try (var p = capture("hg", "status")) {
 433             var output = await(p);
 434             return output.stdout().size() == 0;
 435         }
 436     }
 437 
 438     @Override
 439     public boolean exists() throws IOException {
 440         if (!Files.exists(dir)) {
 441             return false;
 442         }
 443 
 444         try {
 445             root();
 446             return true;
 447         } catch (IOException e) {
 448             return false;
 449         }
 450     }
 451 
 452     private void export(String revset, Path to) throws IOException {
 453         var cmd = List.of("hg", "export", "--git", "--rev", revset);
 454         log.fine("Executing " + String.join(" ", cmd));
 455         var pb = new ProcessBuilder(cmd);
 456         pb.directory(dir.toFile());
 457         pb.redirectError(ProcessBuilder.Redirect.DISCARD);
 458         pb.redirectOutput(to.toFile());
 459         pb.environment().put("HGRCPATH", "");
 460         pb.environment().put("HGPLAIN", "");
 461         var p = pb.start();
 462         try {
 463             await(p);
 464         } catch (Throwable t) {
 465             if (p.isAlive()) {
 466                 try {
 467                     p.waitFor();
 468                 } catch (InterruptedException e) {
 469                     throw new IOException(e);
 470                 }
 471             }
 472 
 473             throw new IOException(t);
 474         }
 475     }
 476 
 477     @Override
 478     public void squash(Hash h) throws IOException {
 479         var revset = ".:" + h.hex() + " and not .";
 480         var patch = Files.createTempFile("squash", ".patch");
 481         export(revset, patch);
 482 
 483         try (var p = capture("hg", "--config", "extensions.mq=", "strip", "--rev", revset)) {
 484             await(p);
 485         }
 486 
 487         try (var p = capture("hg", "import", "--no-commit", patch.toString())) {
 488             await(p);
 489         }
 490     }
 491 
 492 
 493     @Override
 494     public Hash commit(String message, String authorName, String authorEmail)  throws IOException {
 495         return commit(message, authorName, authorEmail, null);
 496     }
 497 
 498     @Override
 499     public Hash commit(String message, String authorName, String authorEmail, ZonedDateTime authorDate)  throws IOException {
 500         var user = authorEmail == null ? authorName : authorName + " <" + authorEmail + ">";
 501         var cmd = new ArrayList<String>();
 502         cmd.addAll(List.of("hg", "commit", "--message=" + message, "--user=" + user));
 503         if (authorDate != null) {
 504             var formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
 505             cmd.add("--date=" + authorDate.format(formatter));
 506         }
 507         try (var p = capture(cmd)) {
 508             await(p);
 509         }
 510         return resolve("tip").orElseThrow(() -> new IOException("Could not resolve 'tip'"));
 511     }
 512 
 513     @Override
 514     public Hash commit(String message,
 515                        String authorName,
 516                        String authorEmail,
 517                        String committerName,
 518                        String committerEmail) throws IOException {
 519         return commit(message, authorName, authorEmail, null, committerName, committerEmail, null);
 520     }
 521 
 522     @Override
 523     public Hash commit(String message,
 524                        String authorName,
 525                        String authorEmail,
 526                        ZonedDateTime authorDate,
 527                        String committerName,
 528                        String committerEmail,
 529                        ZonedDateTime committerDate) throws IOException {
 530         if (!Objects.equals(authorName, committerName) ||
 531             !Objects.equals(authorEmail, committerEmail) ||
 532             !Objects.equals(authorDate, committerDate)) {
 533             throw new IllegalArgumentException("hg does not support different author and committer data");
 534         }
 535 
 536         return commit(message, authorName, authorEmail, authorDate);
 537     }
 538 
 539     @Override
 540     public Hash amend(String message, String authorName, String authorEmail) throws IOException {
 541         var user = authorEmail == null ? authorName : authorName + " <" + authorEmail + ">";
 542         try (var p = capture("hg", "commit", "--amend", "--message=" + message, "--user=" + user)) {
 543             await(p);
 544         }
 545         return resolve("tip").orElseThrow(() -> new IOException("Could not resolve 'tip'"));
 546     }
 547 
 548     @Override
 549     public Hash amend(String message, String authorName, String authorEmail, String committerName, String committerEmail) throws IOException {
 550         if (!Objects.equals(authorName, committerName) ||
 551             !Objects.equals(authorEmail, committerEmail)) {
 552             throw new IllegalArgumentException("hg does not support different author and committer data");
 553         }
 554 
 555         return amend(message, authorName, authorEmail);
 556     }
 557 
 558     @Override
 559     public Tag tag(Hash hash, String name, String message, String authorName, String authorEmail) throws IOException {
 560         var user = authorName + " <" + authorEmail + ">";
 561         try (var p = capture("hg", "tag",
 562                              "--message", message,
 563                              "--user", user,
 564                              "--rev", hash.hex(),
 565                              name)) {
 566             await(p);
 567         }
 568 
 569         return new Tag(name);
 570     }
 571 
 572     @Override
 573     public Branch branch(Hash hash, String name) throws IOException {
 574         // Model a lightweight branch with a bookmark. Not ideal but the
 575         // closest to git branches.
 576         try (var p = capture("hg", "bookmark", "--rev", hash.hex(), name)) {
 577             await(p);
 578         }
 579 
 580         return new Branch(name);
 581     }
 582 
 583     @Override
 584     public Hash mergeBase(Hash first, Hash second) throws IOException {
 585         var revset = "ancestor(" + first.hex() + ", " + second.hex() + ")";
 586         try (var p = capture("hg", "log", "--rev=" + revset, "--template={node}\n")) {
 587             var res = await(p);
 588             if (res.stdout().size() != 1) {
 589                 throw new IOException("Unexpected output\n" + res);
 590             }
 591             return new Hash(res.stdout().get(0));
 592         }
 593     }
 594 
 595     @Override
 596     public boolean isAncestor(Hash ancestor, Hash descendant) throws IOException {
 597         throw new RuntimeException("Not implemented yet");
 598     }
 599 
 600     @Override
 601     public void rebase(Hash hash, String committerName, String committerEmail) throws IOException {
 602         var current = currentBranch().name();
 603         try (var p = capture("hg", "--config", "extensions.rebase=",
 604                              "rebase", "--dest", hash.hex(), "--base", current)) {
 605             await(p);
 606         }
 607     }
 608 
 609     @Override
 610     public Branch currentBranch() throws IOException {
 611         try (var p = capture("hg", "branch")) {
 612             var res = await(p);
 613             if (res.stdout().size() != 1) {
 614                 throw new IllegalStateException("No current branch\n" + res);
 615             }
 616             return new Branch(res.stdout().get(0));
 617         }
 618     }
 619 
 620     @Override
 621     public Optional<Bookmark> currentBookmark() throws IOException {
 622         try (var p = capture("hg", "log", "-r", ".", "--template", "{activebookmark}\n")) {
 623             var res = await(p);
 624             if (res.stdout().size() == 1) {
 625                 return Optional.of(new Bookmark(res.stdout().get(0)));
 626             }
 627             return Optional.empty();
 628         }
 629     }
 630 
 631     @Override
 632     public Branch defaultBranch() throws IOException {
 633         return new Branch("default");
 634     }
 635 
 636     @Override
 637     public Optional<Tag> defaultTag() throws IOException {
 638         return Optional.of(new Tag("tip"));
 639     }
 640 
 641     @Override
 642     public Optional<byte[]> show(Path path, Hash hash) throws IOException {
 643         var output = Files.createTempFile("hg-cat-rev-" + hash.abbreviate(), ".bin");
 644         try (var p = capture("hg", "cat", "--output=" + output, "--rev=" + hash.hex(), path.toString())) {
 645             var res = p.await();
 646             if (res.status() == 0 && Files.exists(output)) {
 647                 var bytes = Files.readAllBytes(output);
 648                 Files.delete(output);
 649                 return Optional.of(bytes);
 650             }
 651 
 652             if (Files.exists(output)) {
 653                 Files.delete(output);
 654             }
 655             return Optional.empty();
 656         }
 657     }
 658 
 659     private List<FileEntry> allFiles(Hash hash, List<Path> paths) throws IOException {
 660         var ext = Files.createTempFile("ext", ".py");
 661         copyResource(EXT_PY, ext);
 662 
 663         var include = new HashSet<>(paths);
 664 
 665         try (var p = capture("hg", "--config", "extensions.ls-tree=" + ext, "ls-tree", hash.hex())) {
 666             var res = await(p);
 667             var entries = new ArrayList<FileEntry>();
 668             for (var line : res.stdout()) {
 669                 var parts = line.split("\t");
 670                 var metadata = parts[0].split(" ");
 671                 var path = Path.of(parts[1]);
 672                 if (include.isEmpty() || include.contains(path)) {
 673                     var entry = new FileEntry(hash,
 674                                               FileType.fromOctal(metadata[0]),
 675                                               new Hash(metadata[2]),
 676                                               path);
 677                     entries.add(entry);
 678                 }
 679             }
 680             return entries;
 681         }
 682     }
 683 
 684     @Override
 685     public List<FileEntry> files(Hash hash, List<Path> paths) throws IOException {
 686         if (paths.isEmpty()) {
 687             return allFiles(hash, paths);
 688         }
 689 
 690         var entries = new ArrayList<FileEntry>();
 691         var batchSize = 64;
 692         var start = 0;
 693         while (start < paths.size()) {
 694             var end = start + batchSize;
 695             if (end > paths.size()) {
 696                 end = paths.size();
 697             }
 698             entries.addAll(allFiles(hash, paths.subList(start, end)));
 699             start = end;
 700         }
 701         return entries;
 702     }
 703 
 704     @Override
 705     public List<StatusEntry> status(Hash from, Hash to) throws IOException {
 706         var ext = Files.createTempFile("ext", ".py");
 707         copyResource(EXT_PY, ext);
 708 
 709         try (var p = capture("hg", "--config", "extensions.diff-git-raw=" + ext.toAbsolutePath().toString(),
 710                                                "diff-git-raw", from.hex(), to.hex())) {
 711             var res = await(p);
 712             var entries = new ArrayList<StatusEntry>();
 713             for (var line : res.stdout()) {
 714                 entries.add(StatusEntry.fromRawLine(line));
 715             }
 716             return entries;
 717         }
 718     }
 719 
 720     @Override
 721     public void dump(FileEntry entry, Path to) throws IOException {
 722         var output = to.toAbsolutePath();
 723         try (var p = capture("hg", "cat", "--output=" + output.toString(),
 724                                           "--rev=" + entry.commit(),
 725                                           entry.path().toString())) {
 726             await(p);
 727         }
 728     }
 729 
 730     @Override
 731     public void revert(Hash parent) throws IOException {
 732         try (var p = capture("hg", "revert", "--no-backup", "--all", "--rev", parent.hex())) {
 733             await(p);
 734         }
 735     }
 736 
 737     @Override
 738     public Diff diff(Hash from) throws IOException {
 739         return diff(from, null);
 740     }
 741 
 742     @Override
 743     public Diff diff(Hash from, Hash to) throws IOException {
 744         var ext = Files.createTempFile("ext", ".py");
 745         copyResource(EXT_PY, ext);
 746 
 747         var cmd = new ArrayList<>(List.of("hg", "--config", "extensions.diff-git-raw=" + ext.toAbsolutePath(),
 748                                                 "diff-git-raw", "--patch", from.hex()));
 749         if (to != null) {
 750             cmd.add(to.hex());
 751         }
 752 
 753         var p = start(cmd);
 754         try {
 755             var patches = UnifiedDiffParser.parseGitRaw(p.getInputStream());
 756             await(p);
 757             return new Diff(from, to, patches);
 758         } catch (Throwable t) {
 759             throw new IOException(t);
 760         }
 761     }
 762 
 763     @Override
 764     public Optional<String> username() throws IOException {
 765         var lines = config("ui.username");
 766         return lines.size() == 1 ? Optional.of(lines.get(0)) : Optional.empty();
 767     }
 768 
 769     @Override
 770     public Hash head() throws IOException {
 771         return resolve(".").orElseThrow(() -> new IOException(". not available"));
 772     }
 773 
 774     private List<Hash> heads() throws IOException {
 775         var heads = new ArrayList<Hash>();
 776         try (var p = capture("hg", "heads", "--template={node}\n")) {
 777             var res = p.await();
 778             if (res.status() == 0) {
 779                 for (var hash : res.stdout()) {
 780                     heads.add(new Hash(hash));
 781                 }
 782             }
 783         }
 784         return heads;
 785     }
 786 
 787     @Override
 788     public List<String> config(String key) throws IOException {
 789         // Do not use HgRepository.capture() here, want to run *with*
 790         // hg configuration.
 791         try (var p = Process.capture("hg", "showconfig", key)
 792                             .workdir(dir)
 793                             .execute()) {
 794             var res = p.await();
 795             if (res.status() == 1) {
 796                 return List.of();
 797             }
 798             return res.stdout();
 799         }
 800     }
 801 
 802     public static Optional<Repository> get(Path p) throws IOException {
 803         if (!Files.exists(p)) {
 804             return Optional.empty();
 805         }
 806 
 807         var r = new HgRepository(p);
 808         return r.exists() ? Optional.of(new HgRepository(r.root())) : Optional.empty();
 809     }
 810 
 811     @Override
 812     public Repository copyTo(Path destination) throws IOException {
 813         var from = root().toAbsolutePath().toString();
 814         var to = destination.toAbsolutePath().toString();
 815         try (var p = capture("hg", "clone", from, to)) {
 816             await(p);
 817         }
 818 
 819         return new HgRepository(destination.toAbsolutePath());
 820     }
 821 
 822     @Override
 823     public void merge(Hash h) throws IOException {
 824         merge(h, null);
 825     }
 826 
 827     @Override
 828     public void merge(Hash h, String stragegy) throws IOException {
 829         var cmd = new ArrayList<String>();
 830         cmd.addAll(List.of("hg", "merge", "--rev=" + h.hex()));
 831         if (stragegy != null) {
 832             cmd.add("--tool=" + stragegy);
 833         }
 834         try (var p = capture(cmd)) {
 835             await(p);
 836         }
 837     }
 838 
 839     @Override
 840     public void abortMerge() throws IOException {
 841         try (var p = capture("hg", "merge", "--abort")) {
 842             await(p);
 843         }
 844 
 845         try (var p = capture("hg", "status", "--unknown", "--no-status")) {
 846             var res = await(p);
 847             for (var path : res.stdout()) {
 848                 if (path.toString().endsWith(".orig")) {
 849                     Files.delete(root().resolve(path));
 850                 }
 851             }
 852         }
 853     }
 854 
 855     @Override
 856     public void addRemote(String name, String path) throws IOException {
 857         setPaths(name, path, path);
 858     }
 859 
 860     @Override
 861     public void setPaths(String remote, String pullPath, String pushPath) throws IOException {
 862         var hgrc = Path.of(root().toString(), ".hg", "hgrc");
 863         if (!Files.exists(hgrc)) {
 864             Files.createFile(hgrc);
 865         }
 866 
 867         var lines = Files.readAllLines(hgrc);
 868         var newLines = new ArrayList<String>();
 869 
 870         var isInPathsSection = false;
 871         var hasPathsSection = false;
 872         for (var line : lines) {
 873             var isSectionHeader = line.startsWith("[") && line.endsWith("]");
 874             if (isSectionHeader && !isInPathsSection) {
 875                 isInPathsSection = line.equals("[paths]");
 876                 if (isInPathsSection) {
 877                     newLines.add(line);
 878                     newLines.add(remote + " = " + (pullPath == null ? "" : pullPath));
 879                     newLines.add(remote + "-push = " + (pushPath == null ? "" : pushPath));
 880                     hasPathsSection = true;
 881                     continue;
 882                 }
 883             }
 884 
 885             if (isInPathsSection && line.startsWith(remote)) {
 886                 if (line.startsWith(remote + "-push")) {
 887                     // skip
 888                 } else if (line.startsWith(remote + ":pushurl")) {
 889                     // skip
 890                 } else if (line.startsWith(remote + " ") || line.startsWith(remote + "=")) {
 891                     // skip
 892                 } else {
 893                     newLines.add(line);
 894                 }
 895             } else {
 896                 newLines.add(line);
 897             }
 898         }
 899 
 900         Files.write(hgrc, newLines, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
 901         if (!hasPathsSection) {
 902             var section = List.of("[paths]",
 903                                   remote + " = " + (pullPath == null ? "" : pullPath),
 904                                   remote + "-push = " + (pushPath == null ? "" : pushPath));
 905             Files.write(hgrc, section, StandardOpenOption.WRITE, StandardOpenOption.APPEND);
 906         }
 907     }
 908 
 909     @Override
 910     public String pullPath(String remote) throws IOException {
 911         var lines = config("paths." + remote);
 912         if (lines.size() != 1) {
 913             throw new IOException("Pull path not found for remote: " + remote);
 914         }
 915         return lines.get(0);
 916     }
 917 
 918     @Override
 919     public String pushPath(String remote) throws IOException {
 920         var lines = config("paths." + remote + "-push");
 921         if (lines.size() != 1) {
 922             lines = config("paths." + remote + "@push");
 923         }
 924         if (lines.size() != 1) {
 925             return pullPath(remote);
 926         }
 927         return lines.get(0);
 928     }
 929 
 930     @Override
 931     public boolean isValidRevisionRange(String expression) throws IOException {
 932         try (var p = capture("hg", "log", "--template", " ", "--rev", expression)) {
 933             return p.await().status() == 0;
 934         }
 935     }
 936 
 937     private void setPermissions(Patch.Info target) throws IOException {
 938         if (target.path().isPresent() && target.type().isPresent()) {
 939             var perms = target.type().get().permissions();
 940             if (perms.isPresent()) {
 941                 Files.setPosixFilePermissions(target.path().get(), perms.get());
 942             }
 943         }
 944     }
 945 
 946     @Override
 947     public void apply(Diff diff, boolean force) throws IOException {
 948         var patchFile = Files.createTempFile("import", ".patch");
 949         diff.toFile(patchFile);
 950         apply(patchFile, force);
 951         Files.delete(patchFile);
 952     }
 953 
 954     @Override
 955     public void apply(Path patchFile, boolean force) throws IOException {
 956         var cmd = new ArrayList<String>();
 957         cmd.addAll(List.of("hg", "import", "--no-commit"));
 958         if (force) {
 959             cmd.add("--force");
 960         }
 961         cmd.add(patchFile.toAbsolutePath().toString());
 962         try (var p = capture(cmd)) {
 963             await(p);
 964         }
 965     }
 966 
 967     @Override
 968     public void copy(Path from, Path to) throws IOException {
 969         try (var p = capture("hg", "copy", from.toString(), to.toString())) {
 970             await(p);
 971         }
 972     }
 973 
 974     @Override
 975     public void move(Path from, Path to) throws IOException {
 976         try (var p = capture("hg", "move", from.toString(), to.toString())) {
 977             await(p);
 978         }
 979     }
 980 
 981     @FunctionalInterface
 982     private static interface Operation {
 983         void execute(List<Path> args) throws IOException;
 984     }
 985 
 986     private void batch(Operation op, List<Path> args) throws IOException {
 987         var batchSize = 64;
 988         var start = 0;
 989         while (start < args.size()) {
 990             var end = start + batchSize;
 991             if (end > args.size()) {
 992                 end = args.size();
 993             }
 994             op.execute(args.subList(start, end));
 995             start = end;
 996         }
 997     }
 998 
 999     private void addAll(List<Path> paths) throws IOException {
1000         var cmd = new ArrayList<>(List.of("hg", "add"));
1001         for (var path : paths) {
1002             cmd.add(path.toString());
1003         }
1004         try (var p = capture(cmd)) {
1005             await(p);
1006         }
1007     }
1008 
1009     private void removeAll(List<Path> paths) throws IOException {
1010         var cmd = new ArrayList<>(List.of("hg", "rm"));
1011         for (var path : paths) {
1012             cmd.add(path.toString());
1013         }
1014         try (var p = capture(cmd)) {
1015             await(p);
1016         }
1017     }
1018 
1019 
1020     @Override
1021     public void remove(List<Path> paths) throws IOException {
1022         batch(this::removeAll, paths);
1023     }
1024 
1025     @Override
1026     public void add(List<Path> paths) throws IOException {
1027         batch(this::addAll, paths);
1028     }
1029 
1030     @Override
1031     public void addremove() throws IOException {
1032         try (var p = capture("hg", "addremove")) {
1033             await(p);
1034         }
1035     }
1036 
1037     @Override
1038     public Optional<String> upstreamFor(Branch b) throws IOException {
1039         // Mercurial doesn't have the concept of remotes like git,
1040         // a local branch must have the same name (if present) on the remote
1041         return Optional.of(b.name());
1042     }
1043 
1044     public static Repository clone(URI from, Path to, boolean isBare) throws IOException {
1045         var cmd = new ArrayList<String>();
1046         cmd.addAll(List.of("hg", "clone"));
1047         if (isBare) {
1048             cmd.add("--noupdate");
1049         }
1050         cmd.addAll(List.of(from.toString(), to.toString()));
1051 
1052         try (var p = capture(Path.of("").toAbsolutePath(), cmd)) {
1053             await(p);
1054         }
1055         return new HgRepository(to);
1056     }
1057 
1058     @Override
1059     public void pull() throws IOException {
1060         pull(null, null);
1061     }
1062 
1063     @Override
1064     public void pull(String remote) throws IOException {
1065         pull(remote, null);
1066     }
1067 
1068     @Override
1069     public void pull(String remote, String refspec) throws IOException {
1070         var cmd = new ArrayList<String>();
1071         cmd.addAll(List.of("hg", "pull", "--update"));
1072         if (refspec != null) {
1073             cmd.add("--branch");
1074             cmd.add(refspec);
1075         }
1076         if (remote != null) {
1077             cmd.add(remote);
1078         }
1079         try (var p = capture(cmd)) {
1080             await(p);
1081         }
1082     }
1083 
1084     @Override
1085     public boolean contains(Branch b, Hash h) throws IOException {
1086         try (var p = capture("hg", "log", "--template", "{branch}", "-r", h.hex())) {
1087             var res = await(p);
1088             if (res.stdout().size() != 1) {
1089                 throw new IOException("Unexpected output: " + String.join("\n", res.stdout()));
1090             }
1091             var line = res.stdout().get(0);
1092             return line.equals(b.name());
1093         }
1094     }
1095 
1096     @Override
1097     public List<Reference> remoteBranches(String remote) throws IOException {
1098         var refs = new ArrayList<Reference>();
1099 
1100         var ext = Files.createTempFile("ext", ".py");
1101         copyResource(EXT_PY, ext);
1102 
1103         try (var p = capture("hg", "--config", "extensions.ls-remote=" + ext, "ls-remote", remote)) {
1104             var res = await(p);
1105             for (var line : res.stdout()) {
1106                 var parts = line.split("\t");
1107                 refs.add(new Reference(parts[1], new Hash(parts[0])));
1108             }
1109         }
1110         return refs;
1111     }
1112 
1113     @Override
1114     public List<String> remotes() throws IOException {
1115         var remotes = new ArrayList<String>();
1116         try (var p = capture("hg", "paths")) {
1117             for (var line : await(p).stdout()) {
1118                 var parts = line.split(" = ");
1119                 var name = parts[0];
1120                 if (name.endsWith("-push") || name.endsWith(":push")) {
1121                     continue;
1122                 } else {
1123                     remotes.add(name);
1124                 }
1125             }
1126         }
1127         return remotes;
1128     }
1129 }