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.git;
  24 
  25 import org.openjdk.skara.process.*;
  26 import org.openjdk.skara.process.Process;
  27 import org.openjdk.skara.vcs.*;
  28 import org.openjdk.skara.vcs.tools.*;
  29 
  30 import java.io.*;
  31 import java.net.URI;
  32 import java.nio.file.*;
  33 import java.nio.charset.StandardCharsets;
  34 import java.time.*;
  35 import java.time.format.DateTimeFormatter;
  36 import java.util.*;
  37 import java.util.logging.Logger;
  38 import java.util.stream.Collectors;
  39 
  40 public class GitRepository implements Repository {
  41     private final Path dir;
  42     private final Logger log = Logger.getLogger("org.openjdk.skara.vcs.git");
  43     private Path cachedRoot = null;
  44 
  45     private java.lang.Process start(String... cmd) throws IOException {
  46         return start(Arrays.asList(cmd));
  47     }
  48 
  49     private java.lang.Process start(List<String> cmd) throws IOException {
  50         log.fine("Executing " + String.join(" ", cmd));
  51         var pb = new ProcessBuilder(cmd);
  52         pb.directory(dir.toFile());
  53         pb.redirectError(ProcessBuilder.Redirect.DISCARD);
  54         return pb.start();
  55     }
  56 
  57     private static void stop(java.lang.Process p) throws IOException {
  58         if (p != null && p.isAlive()) {
  59             var stream = p.getInputStream();
  60             var read = 0;
  61             var buf = new byte[128];
  62             while (read != -1) {
  63                 read = stream.read(buf);
  64             }
  65             try {
  66                 p.waitFor();
  67             } catch (InterruptedException e) {
  68                 throw new IOException(e);
  69             }
  70         }
  71     }
  72 
  73     private Execution capture(List<String> cmd) {
  74         return capture(cmd.toArray(new String[0]));
  75     }
  76 
  77     private Execution capture(String... cmd) {
  78         return capture(dir, cmd);
  79     }
  80 
  81     private static Execution capture(Path cwd, String... cmd) {
  82         return Process.capture(cmd)
  83                       .workdir(cwd)
  84                       .execute();
  85     }
  86 
  87     private static Execution capture(Path cwd, List<String> cmd) {
  88         return capture(cwd, cmd.toArray(new String[0]));
  89     }
  90 
  91     private static Execution.Result await(Execution e) throws IOException {
  92         var result = e.await();
  93         if (result.status() != 0) {
  94             throw new IOException("Unexpected exit code\n" + result);
  95         }
  96         return result;
  97     }
  98 
  99     private static void await(java.lang.Process p) throws IOException {
 100         try {
 101             var res = p.waitFor();
 102             if (res != 0) {
 103                 throw new IOException("Unexpected exit code: " + res);
 104             }
 105         } catch (InterruptedException e) {
 106             throw new IOException(e);
 107         }
 108     }
 109 
 110     public GitRepository(Path dir) {
 111         this.dir = dir.toAbsolutePath();
 112     }
 113 
 114     public List<Branch> branches() throws IOException {
 115         try (var p = capture("git", "for-each-ref", "--format=%(refname:short)", "refs/heads")) {
 116             return await(p).stdout()
 117                            .stream()
 118                            .map(Branch::new)
 119                            .collect(Collectors.toList());
 120         }
 121     }
 122 
 123     public List<Tag> tags() throws IOException {
 124         try (var p = capture("git", "for-each-ref", "--format=%(refname:short)", "refs/tags")) {
 125             return await(p).stdout()
 126                            .stream()
 127                            .map(Tag::new)
 128                            .collect(Collectors.toList());
 129         }
 130     }
 131 
 132     @Override
 133     public Commits commits() throws IOException {
 134         return new GitCommits(dir, "--all", false, -1);
 135     }
 136 
 137     @Override
 138     public Commits commits(int n) throws IOException {
 139         return new GitCommits(dir, "--all", false, n);
 140     }
 141 
 142     @Override
 143     public Commits commits(boolean reverse) throws IOException {
 144         return new GitCommits(dir, "--all", reverse, -1);
 145     }
 146 
 147     @Override
 148     public Commits commits(int n, boolean reverse) throws IOException {
 149         return new GitCommits(dir, "--all", reverse, n);
 150     }
 151 
 152     @Override
 153     public Commits commits(String range) throws IOException {
 154         return new GitCommits(dir, range, false, -1);
 155     }
 156 
 157     @Override
 158     public Commits commits(String range, int n) throws IOException {
 159         return new GitCommits(dir, range, false, n);
 160     }
 161 
 162     @Override
 163     public Commits commits(String range, boolean reverse) throws IOException {
 164         return new GitCommits(dir, range, reverse, -1);
 165     }
 166 
 167     @Override
 168     public Commits commits(String range, int n, boolean reverse) throws IOException {
 169         return new GitCommits(dir, range, reverse, n);
 170     }
 171 
 172     @Override
 173     public Optional<Commit> lookup(Hash h) throws IOException {
 174         var commits = commits(h.hex(), 1).asList();
 175         if (commits.size() != 1) {
 176             return Optional.empty();
 177         }
 178         return Optional.of(commits.get(0));
 179     }
 180 
 181     @Override
 182     public Optional<Commit> lookup(Branch b) throws IOException {
 183         var hash = resolve(b.name()).orElseThrow(() -> new IOException("Branch " + b.name() + " not found"));
 184         return lookup(hash);
 185     }
 186 
 187     @Override
 188     public Optional<Commit> lookup(Tag t) throws IOException {
 189         var hash = resolve(t.name()).orElseThrow(() -> new IOException("Tag " + t.name() + " not found"));
 190         return lookup(hash);
 191     }
 192 
 193     public List<CommitMetadata> commitMetadata() throws IOException {
 194         var revisions = "--all";
 195         var p = start("git", "rev-list", "--format=" + GitCommitMetadata.FORMAT, "--no-abbrev", "--reverse", "--no-color", revisions);
 196         var reader = new UnixStreamReader(p.getInputStream());
 197         var result = new ArrayList<CommitMetadata>();
 198 
 199         var line = reader.readLine();
 200         while (line != null) {
 201             if (!line.startsWith("commit")) {
 202                 throw new IOException("Unexpected line: " + line);
 203             }
 204 
 205             result.add(GitCommitMetadata.read(reader));
 206             line = reader.readLine();
 207         }
 208 
 209         await(p);
 210         return result;
 211     }
 212 
 213     private List<Hash> refs() throws IOException {
 214         try (var p = capture("git", "show-ref", "--hash", "--abbrev")) {
 215             var res = p.await();
 216             if (res.status() == -1) {
 217                 if (res.stdout().size() != 0) {
 218                     throw new IOException("Unexpected output\n" + res);
 219                 }
 220                 return new ArrayList<>();
 221             } else {
 222                 return res.stdout().stream()
 223                           .map(Hash::new)
 224                           .collect(Collectors.toList());
 225             }
 226         }
 227     }
 228 
 229     @Override
 230     public boolean isEmpty() throws IOException {
 231         int numLooseObjects = -1;
 232         int numPackedObjects = -1;
 233 
 234         try (var p = capture("git", "count-objects", "-v")) {
 235             var res = await(p);
 236             var stdout = res.stdout();
 237 
 238             for (var line : stdout) {
 239                 if (line.startsWith("count: ")) {
 240                     try {
 241                         numLooseObjects = Integer.parseUnsignedInt(line.split(" ")[1]);
 242                     } catch (NumberFormatException e) {
 243                         throw new IOException("Unexpected 'count' value\n" + res, e);
 244                     }
 245 
 246                 } else if (line.startsWith("in-pack: ")) {
 247                     try {
 248                         numPackedObjects = Integer.parseUnsignedInt(line.split(" ")[1]);
 249                     } catch (NumberFormatException e) {
 250                         throw new IOException("Unexpected 'in-pack' value\n" + res, e);
 251                     }
 252                 }
 253             }
 254         }
 255 
 256         return numLooseObjects == 0 && numPackedObjects == 0 && refs().size() == 0;
 257     }
 258 
 259     @Override
 260     public boolean isHealthy() throws IOException {
 261         var refs = refs();
 262         if (refs.size() == 0) {
 263             return true;
 264         }
 265 
 266         var name = "health-check";
 267         try (var p = capture("git", "branch", name, refs.get(0).hex())) {
 268             if (p.await().status() != 0) {
 269                 return false;
 270             }
 271         }
 272         try (var p = capture("git", "branch", "-D", name)) {
 273             if (p.await().status() != 0) {
 274                 return false;
 275             }
 276         }
 277 
 278         return true;
 279     }
 280 
 281     @Override
 282     public void clean() throws IOException {
 283         cachedRoot = null;
 284 
 285         try (var p = capture("git", "clean", "-x", "-d", "--force", "--force")) {
 286             await(p);
 287         }
 288 
 289         try (var p = capture("git", "reset", "--hard")) {
 290             await(p);
 291         }
 292 
 293         try (var p = capture("git", "rebase", "--quit")) {
 294             p.await(); // Don't care about the result.
 295         }
 296     }
 297 
 298     @Override
 299     public void reset(Hash target, boolean hard) throws IOException {
 300         var cmd = new ArrayList<>(List.of("git", "reset"));
 301         if (hard) {
 302            cmd.add("--hard");
 303         }
 304         cmd.add(target.hex());
 305 
 306         try (var p = capture(cmd)) {
 307             await(p);
 308         }
 309     }
 310 
 311 
 312     @Override
 313     public void revert(Hash h) throws IOException {
 314         try (var p = capture("git", "checkout", h.hex(), "--", ".")) {
 315             await(p);
 316         }
 317     }
 318 
 319     @Override
 320     public Repository reinitialize() throws IOException {
 321         cachedRoot = null;
 322 
 323         Files.walk(dir)
 324              .map(Path::toFile)
 325              .sorted(Comparator.reverseOrder())
 326              .forEach(File::delete);
 327 
 328         return init();
 329     }
 330 
 331     @Override
 332     public Hash fetch(URI uri, String refspec) throws IOException {
 333         try (var p = capture("git", "fetch", "--tags", uri.toString(), refspec)) {
 334             await(p);
 335             return resolve("FETCH_HEAD").get();
 336         }
 337     }
 338 
 339     @Override
 340     public void fetchAll() throws IOException {
 341         try (var p = capture("git", "fetch", "--tags", "--prune", "--prune-tags", "--all")) {
 342             await(p);
 343         }
 344     }
 345 
 346     private void checkout(String ref, boolean force) throws IOException {
 347         var cmd = new ArrayList<String>();
 348         cmd.addAll(List.of("git", "-c", "advice.detachedHead=false", "checkout"));
 349         if (force) {
 350             cmd.add("--force");
 351         }
 352         cmd.add(ref);
 353         try (var p = capture(cmd)) {
 354             await(p);
 355         }
 356     }
 357 
 358     @Override
 359     public void checkout(Hash h, boolean force) throws IOException {
 360         checkout(h.hex(), force);
 361     }
 362 
 363     @Override
 364     public void checkout(Branch b, boolean force) throws IOException {
 365         checkout(b.name(), force);
 366     }
 367 
 368     @Override
 369     public Repository init() throws IOException {
 370         cachedRoot = null;
 371 
 372         if (!Files.exists(dir)) {
 373             Files.createDirectories(dir);
 374         }
 375 
 376         try (var p = capture("git", "init")) {
 377             await(p);
 378             return this;
 379         }
 380     }
 381 
 382     @Override
 383     public void pushAll(URI uri) throws IOException {
 384         try (var p = capture("git", "push", "--mirror", uri.toString())) {
 385             await(p);
 386         }
 387     }
 388 
 389     @Override
 390     public void push(Hash hash, URI uri, String ref, boolean force) throws IOException {
 391         String refspec = force ? "+" : "";
 392         if (!ref.startsWith("refs/")) {
 393             ref = "refs/heads/" + ref;
 394         }
 395         refspec += hash.hex() + ":" + ref;
 396 
 397         try (var p = capture("git", "push", uri.toString(), refspec)) {
 398             await(p);
 399         }
 400     }
 401 
 402     @Override
 403     public void push(Branch branch, String remote, boolean setUpstream) throws IOException {
 404         var cmd = new ArrayList<String>();
 405         cmd.addAll(List.of("git", "push", remote, branch.name()));
 406         if (setUpstream) {
 407             cmd.add("--set-upstream");
 408         }
 409 
 410         try (var p = capture(cmd)) {
 411             await(p);
 412         }
 413     }
 414 
 415     @Override
 416     public boolean isClean() throws IOException {
 417         try (var p = capture("git", "status", "--porcelain")) {
 418             var output = await(p);
 419             return output.stdout().size() == 0;
 420         }
 421     }
 422 
 423     @Override
 424     public boolean exists() throws IOException {
 425         if (!Files.exists(dir)) {
 426             return false;
 427         }
 428 
 429         try (var p = capture("git", "rev-parse", "--git-dir")) {
 430             return p.await().status() == 0;
 431         }
 432     }
 433 
 434     @Override
 435     public Path root() throws IOException {
 436         if (cachedRoot != null) {
 437             return cachedRoot;
 438         }
 439 
 440         try (var p = capture("git", "rev-parse", "--show-toplevel")) {
 441             var res = await(p);
 442             if (res.stdout().size() != 1) {
 443                 // Perhaps this is a bare repository
 444                 try (var p2 = capture("git", "rev-parse", "--git-dir")) {
 445                     var res2 = await(p2);
 446                     if (res2.stdout().size() != 1) {
 447                         throw new IOException("Unexpected output\n" + res2);
 448                     }
 449                     cachedRoot = dir.resolve(Path.of(res2.stdout().get(0)));
 450                     return cachedRoot;
 451                 }
 452             }
 453 
 454             cachedRoot = Path.of(res.stdout().get(0));
 455             return cachedRoot;
 456         }
 457     }
 458 
 459     @Override
 460     public void squash(Hash h) throws IOException {
 461         try (var p = capture("git", "merge", "--squash", h.hex())) {
 462             await(p);
 463         }
 464     }
 465 
 466     @FunctionalInterface
 467     private static interface Operation {
 468         void execute(List<Path> args) throws IOException;
 469     }
 470 
 471     private void batch(Operation op, List<Path> args) throws IOException {
 472         var batchSize = 64;
 473         var start = 0;
 474         while (start < args.size()) {
 475             var end = start + batchSize;
 476             if (end > args.size()) {
 477                 end = args.size();
 478             }
 479             op.execute(args.subList(start, end));
 480             start = end;
 481         }
 482     }
 483 
 484     private void addAll(List<Path> paths) throws IOException {
 485         var cmd = new ArrayList<>(List.of("git", "add"));
 486         for (var path : paths) {
 487             cmd.add(path.toString());
 488         }
 489         try (var p = capture(cmd)) {
 490             await(p);
 491         }
 492     }
 493 
 494     @Override
 495     public void add(List<Path> paths) throws IOException {
 496         batch(this::addAll, paths);
 497     }
 498 
 499     private void removeAll(List<Path> paths) throws IOException {
 500         var cmd = new ArrayList<>(List.of("git", "rm"));
 501         for (var path : paths) {
 502             cmd.add(path.toString());
 503         }
 504         try (var p = capture(cmd)) {
 505             await(p);
 506         }
 507     }
 508 
 509     @Override
 510     public void remove(List<Path> paths) throws IOException {
 511         batch(this::removeAll, paths);
 512     }
 513 
 514     @Override
 515     public void delete(Branch b) throws IOException {
 516         try (var p = capture("git", "branch", "-D", b.name())) {
 517             await(p);
 518         }
 519     }
 520 
 521     @Override
 522     public void addremove() throws IOException {
 523         try (var p = capture("git", "add", "--all")) {
 524             await(p);
 525         }
 526     }
 527 
 528     @Override
 529     public Hash commit(String message, String authorName, String authorEmail)  throws IOException {
 530         return commit(message, authorName, authorEmail, null);
 531     }
 532 
 533     @Override
 534     public Hash commit(String message, String authorName, String authorEmail, ZonedDateTime authorDate)  throws IOException {
 535         return commit(message, authorName, authorEmail, authorDate, authorName, authorEmail, authorDate);
 536     }
 537 
 538     @Override
 539     public Hash commit(String message,
 540                        String authorName,
 541                        String authorEmail,
 542                        String committerName,
 543                        String committerEmail) throws IOException {
 544         return commit(message, authorName, authorEmail, null, committerName, committerEmail, null);
 545     }
 546 
 547     @Override
 548     public Hash commit(String message,
 549                        String authorName,
 550                        String authorEmail,
 551                        ZonedDateTime authorDate,
 552                        String committerName,
 553                        String committerEmail,
 554                        ZonedDateTime committerDate) throws IOException {
 555         var cmd = Process.capture("git", "commit", "--message=" + message)
 556                          .workdir(dir)
 557                          .environ("GIT_AUTHOR_NAME", authorName)
 558                          .environ("GIT_AUTHOR_EMAIL", authorEmail)
 559                          .environ("GIT_COMMITTER_NAME", committerName)
 560                          .environ("GIT_COMMITTER_EMAIL", committerEmail);
 561         if (authorDate != null) {
 562             cmd = cmd.environ("GIT_AUTHOR_DATE",
 563                               authorDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
 564         }
 565         if (committerDate != null) {
 566             cmd = cmd.environ("GIT_COMMITTER_DATE",
 567                               committerDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
 568         }
 569         try (var p = cmd.execute()) {
 570             await(p);
 571             return head();
 572         }
 573     }
 574 
 575     @Override
 576     public Hash amend(String message, String authorName, String authorEmail) throws IOException {
 577         return amend(message, authorName, authorEmail, null, null);
 578     }
 579 
 580     @Override
 581     public Hash amend(String message, String authorName, String authorEmail, String committerName, String committerEmail) throws IOException {
 582         if (committerName == null) {
 583             committerName = authorName;
 584             committerEmail = authorEmail;
 585         }
 586         var cmd = Process.capture("git", "commit", "--amend", "--reset-author", "--message=" + message)
 587                          .workdir(dir)
 588                          .environ("GIT_AUTHOR_NAME", authorName)
 589                          .environ("GIT_AUTHOR_EMAIL", authorEmail)
 590                          .environ("GIT_COMMITTER_NAME", committerName)
 591                          .environ("GIT_COMMITTER_EMAIL", committerEmail);
 592         try (var p = cmd.execute()) {
 593             await(p);
 594             return head();
 595         }
 596     }
 597 
 598     @Override
 599     public Tag tag(Hash hash, String name, String message, String authorName, String authorEmail) throws IOException {
 600         var cmd = Process.capture("git", "tag", "--annotate", "--message=" + message, name, hash.hex())
 601                          .workdir(dir)
 602                          .environ("GIT_AUTHOR_NAME", authorName)
 603                          .environ("GIT_AUTHOR_EMAIL", authorEmail)
 604                          .environ("GIT_COMMITTER_NAME", authorName)
 605                          .environ("GIT_COMMITTER_EMAIL", authorEmail);
 606         try (var p = cmd.execute()) {
 607             await(p);
 608         }
 609 
 610         return new Tag(name);
 611     }
 612 
 613     @Override
 614     public Branch branch(Hash hash, String name) throws IOException {
 615         try (var p = capture("git", "branch", name, hash.hex())) {
 616             await(p);
 617         }
 618 
 619         return new Branch(name);
 620     }
 621 
 622     @Override
 623     public Hash mergeBase(Hash first, Hash second) throws IOException {
 624         try (var p = capture("git", "merge-base", first.hex(), second.hex())) {
 625             var res = await(p);
 626             if (res.stdout().size() != 1) {
 627                  throw new IOException("Unexpected output\n" + res);
 628             }
 629             return new Hash(res.stdout().get(0));
 630         }
 631     }
 632 
 633     @Override
 634     public boolean isAncestor(Hash ancestor, Hash descendant) throws IOException {
 635         try (var p = capture("git", "merge-base", "--is-ancestor", ancestor.hex(), descendant.hex())) {
 636             var res = p.await();
 637             return res.status() == 0;
 638         }
 639     }
 640 
 641     @Override
 642     public void rebase(Hash hash, String committerName, String committerEmail) throws IOException {
 643         try (var p = Process.capture("git", "rebase", "--onto", hash.hex(), "--root", "--rebase-merges")
 644                             .environ("GIT_COMMITTER_NAME", committerName)
 645                             .environ("GIT_COMMITTER_EMAIL", committerEmail)
 646                             .workdir(dir)
 647                             .execute()) {
 648             await(p);
 649         }
 650     }
 651 
 652     @Override
 653     public Optional<Hash> resolve(String ref) throws IOException {
 654         try (var p = capture("git", "rev-parse", ref + "^{commit}")) {
 655             var res = p.await();
 656             if (res.status() == 0 && res.stdout().size() == 1) {
 657                 return Optional.of(new Hash(res.stdout().get(0)));
 658             }
 659             return Optional.empty();
 660         }
 661     }
 662 
 663     @Override
 664     public Branch currentBranch() throws IOException {
 665         try (var p = capture("git", "symbolic-ref", "--short", "HEAD")) {
 666             var res = await(p);
 667             if (res.stdout().size() != 1) {
 668                 throw new IOException("Unexpected output\n" + res);
 669             }
 670             return new Branch(res.stdout().get(0));
 671         }
 672     }
 673 
 674     @Override
 675     public Optional<Bookmark> currentBookmark() throws IOException {
 676         throw new RuntimeException("git does not have bookmarks");
 677     }
 678 
 679     @Override
 680     public Branch defaultBranch() throws IOException {
 681         try (var p = capture("git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD")) {
 682             var res = p.await();
 683             if (res.status() == 0 && res.stdout().size() == 1) {
 684                 var ref = res.stdout().get(0).substring("origin/".length());
 685                 return new Branch(ref);
 686             } else {
 687                 return new Branch("master");
 688             }
 689         }
 690     }
 691 
 692     @Override
 693     public Optional<Tag> defaultTag() throws IOException {
 694         return Optional.empty();
 695     }
 696 
 697     @Override
 698     public Optional<String> username() throws IOException {
 699         var lines = config("user.name");
 700         return lines.size() == 1 ? Optional.of(lines.get(0)) : Optional.empty();
 701     }
 702 
 703     private String treeEntry(Path path, Hash hash) throws IOException {
 704         try (var p = Process.capture("git", "ls-tree", hash.hex(), path.toString())
 705                             .workdir(root())
 706                             .execute()) {
 707             var res = await(p);
 708             if (res.stdout().size() == 0) {
 709                 return null;
 710             }
 711             if (res.stdout().size() > 1) {
 712                 throw new IOException("Unexpected output\n" + res);
 713             }
 714             return res.stdout().get(0);
 715         }
 716     }
 717 
 718     private List<FileEntry> allFiles(Hash hash, List<Path> paths) throws IOException {
 719         var cmd = new ArrayList<String>();
 720         cmd.addAll(List.of("git", "ls-tree", "-r"));
 721         cmd.add(hash.hex());
 722         cmd.addAll(paths.stream().map(Path::toString).collect(Collectors.toList()));
 723         try (var p = Process.capture(cmd.toArray(new String[0]))
 724                             .workdir(root())
 725                             .execute()) {
 726             var res = await(p);
 727             var entries = new ArrayList<FileEntry>();
 728             for (var line : res.stdout()) {
 729                 var parts = line.split("\t");
 730                 var metadata = parts[0].split(" ");
 731                 var filename = parts[1];
 732 
 733                 var entry = new FileEntry(hash,
 734                                           FileType.fromOctal(metadata[0]),
 735                                           new Hash(metadata[2]),
 736                                           Path.of(filename));
 737                 entries.add(entry);
 738             }
 739             return entries;
 740         }
 741     }
 742 
 743     @Override
 744     public List<FileEntry> files(Hash hash, List<Path> paths) throws IOException {
 745         if (paths.isEmpty()) {
 746             return allFiles(hash, paths);
 747         }
 748 
 749         var entries = new ArrayList<FileEntry>();
 750         var batchSize = 64;
 751         var start = 0;
 752         while (start < paths.size()) {
 753             var end = start + batchSize;
 754             if (end > paths.size()) {
 755                 end = paths.size();
 756             }
 757             entries.addAll(allFiles(hash, paths.subList(start, end)));
 758             start = end;
 759         }
 760         return entries;
 761     }
 762 
 763     private Path unpackFile(String blob) throws IOException {
 764         try (var p = capture("git", "unpack-file", blob)) {
 765             var res = await(p);
 766             if (res.stdout().size() != 1) {
 767                 throw new IOException("Unexpected output\n" + res);
 768             }
 769 
 770             return Path.of(root().toString(), res.stdout().get(0));
 771         }
 772     }
 773 
 774     @Override
 775     public Optional<byte[]> show(Path path, Hash hash) throws IOException {
 776         var entries = files(hash, path);
 777         if (entries.size() == 0) {
 778             return Optional.empty();
 779         } else if (entries.size() > 1) {
 780             throw new IOException("Multiple files match path " + path.toString() + " in commit " + hash.hex());
 781         }
 782 
 783         var entry = entries.get(0);
 784         var type = entry.type();
 785         if (type.isVCSLink()) {
 786             var content = "Subproject commit " + entry.hash().hex() + " " + entry.path().toString();
 787             return Optional.of(content.getBytes(StandardCharsets.UTF_8));
 788         } else if (type.isRegular()) {
 789             var tmp = unpackFile(entry.hash().hex());
 790             var content = Files.readAllBytes(tmp);
 791             Files.delete(tmp);
 792             return Optional.of(content);
 793         }
 794 
 795         return Optional.empty();
 796     }
 797 
 798     @Override
 799     public void dump(FileEntry entry, Path to) throws IOException {
 800         var type = entry.type();
 801         if (type.isRegular()) {
 802             var path = unpackFile(entry.hash().hex());
 803             Files.createDirectories(to.getParent());
 804             Files.move(path, to, StandardCopyOption.REPLACE_EXISTING);
 805         }
 806     }
 807 
 808     @Override
 809     public List<StatusEntry> status(Hash from, Hash to) throws IOException {
 810         try (var p = capture("git", "diff", "--raw", "--find-renames=99%", "--find-copies=99%", "--find-copies-harder", "--no-abbrev", "--no-color", from.hex(), to.hex())) {
 811             var res = await(p);
 812             var entries = new ArrayList<StatusEntry>();
 813             for (var line : res.stdout()) {
 814                 entries.add(StatusEntry.fromRawLine(line));
 815             }
 816             return entries;
 817         }
 818     }
 819 
 820     @Override
 821     public Diff diff(Hash from) throws IOException {
 822         return diff(from, null);
 823     }
 824 
 825     @Override
 826     public Diff diff(Hash from, Hash to) throws IOException {
 827         var cmd = new ArrayList<>(List.of("git", "diff", "--patch",
 828                                                          "--find-renames=99%",
 829                                                          "--find-copies=99%",
 830                                                          "--find-copies-harder",
 831                                                          "--binary",
 832                                                          "--raw",
 833                                                          "--no-abbrev",
 834                                                          "--unified=0",
 835                                                          "--no-color",
 836                                                          from.hex()));
 837         if (to != null) {
 838             cmd.add(to.hex());
 839         }
 840 
 841         var p = start(cmd);
 842         try {
 843             var patches = UnifiedDiffParser.parseGitRaw(p.getInputStream());
 844             await(p);
 845             return new Diff(from, to, patches);
 846         } catch (Throwable t) {
 847             stop(p);
 848             throw t;
 849         }
 850     }
 851 
 852     @Override
 853     public List<String> config(String key) throws IOException {
 854         try (var p = capture("git", "config", key)) {
 855             var res = p.await();
 856             return res.status() == 0 ? res.stdout() : List.of();
 857         }
 858     }
 859 
 860     @Override
 861     public Hash head() throws IOException {
 862         return resolve("HEAD").orElseThrow(() -> new IllegalStateException("HEAD ref is not present"));
 863     }
 864 
 865     public static Optional<Repository> get(Path p) throws IOException {
 866         if (!Files.exists(p)) {
 867             return Optional.empty();
 868         }
 869 
 870         var r = new GitRepository(p);
 871         return r.exists() ? Optional.of(new GitRepository(r.root())) : Optional.empty();
 872     }
 873 
 874     @Override
 875     public Repository copyTo(Path destination) throws IOException {
 876         try (var p = capture("git", "clone", root().toString(), destination.toString())) {
 877             await(p);
 878         }
 879 
 880         return new GitRepository(destination);
 881     }
 882 
 883     @Override
 884     public void merge(Hash h) throws IOException {
 885         merge(h, null);
 886     }
 887 
 888     @Override
 889     public void merge(Hash h, String strategy) throws IOException {
 890         var cmd = new ArrayList<String>();
 891         cmd.addAll(List.of("git", "-c", "user.name=unused", "-c", "user.email=unused",
 892                            "merge", "--no-commit"));
 893         if (strategy != null) {
 894             cmd.add("-s");
 895             cmd.add(strategy);
 896         }
 897         cmd.add(h.hex());
 898         try (var p = capture(cmd)) {
 899             await(p);
 900         }
 901     }
 902 
 903     @Override
 904     public void abortMerge() throws IOException {
 905         try (var p = capture("git", "merge", "--abort")) {
 906             await(p);
 907         }
 908     }
 909 
 910     @Override
 911     public void addRemote(String name, String pullPath) throws IOException {
 912         try (var p = capture("git", "remote", "add", name, pullPath)) {
 913             await(p);
 914         }
 915     }
 916 
 917     @Override
 918     public void setPaths(String remote, String pullPath, String pushPath) throws IOException {
 919         pullPath = pullPath == null ? "" : pullPath;
 920         try (var p = capture("git", "config", "remote." + remote + ".url", pullPath)) {
 921             await(p);
 922         }
 923 
 924         pushPath = pushPath == null ? "" : pushPath;
 925         try (var p = capture("git", "config", "remote." + remote + ".pushurl", pushPath)) {
 926             await(p);
 927         }
 928     }
 929 
 930     @Override
 931     public String pullPath(String remote) throws IOException {
 932         var lines = config("remote." + remote + ".url");
 933         if (lines.size() != 1) {
 934             throw new IOException("No pull path found for remote " + remote);
 935         }
 936         return lines.get(0);
 937     }
 938 
 939     @Override
 940     public String pushPath(String remote) throws IOException {
 941         var lines = config("remote." + remote + ".pushurl");
 942         if (lines.size() != 1) {
 943             return pullPath(remote);
 944         }
 945         return lines.get(0);
 946     }
 947 
 948     @Override
 949     public boolean isValidRevisionRange(String expression) throws IOException {
 950         try (var p = capture("git", "rev-parse", expression)) {
 951             return p.await().status() == 0;
 952         }
 953     }
 954 
 955     private void applyPatch(Patch patch) throws IOException {
 956         if (patch.isEmpty()) {
 957             return;
 958         }
 959 
 960         if (patch.isTextual()) {
 961         } else {
 962             throw new IllegalArgumentException("Cannot handle binary patches yet");
 963         }
 964     }
 965 
 966     @Override
 967     public void apply(Diff diff, boolean force) throws IOException {
 968         // ignore force, no such concept in git
 969         var patchFile = Files.createTempFile("apply", ".patch");
 970         diff.toFile(patchFile);
 971         apply(patchFile, force);
 972         Files.delete(patchFile);
 973     }
 974 
 975     @Override
 976     public void apply(Path patchFile, boolean force)  throws IOException {
 977         var cmd = new ArrayList<String>();
 978         cmd.addAll(List.of("git", "apply", "--index", "--unidiff-zero"));
 979         cmd.add(patchFile.toAbsolutePath().toString());
 980         try (var p = capture(cmd)) {
 981             await(p);
 982             Files.delete(patchFile);
 983         }
 984     }
 985 
 986     @Override
 987     public void copy(Path from, Path to) throws IOException {
 988         Files.copy(from, to);
 989         add(to);
 990     }
 991 
 992     @Override
 993     public void move(Path from, Path to) throws IOException {
 994         try (var p = capture("git", "mv", from.toString(), to.toString())) {
 995             await(p);
 996         }
 997     }
 998 
 999     @Override
1000     public Optional<String> upstreamFor(Branch b) throws IOException {
1001         try (var p = capture("git", "for-each-ref", "--format=%(upstream:short)", "refs/heads/" + b.name())) {
1002             var lines = await(p).stdout();
1003             return lines.size() == 1 && !lines.get(0).isEmpty()? Optional.of(lines.get(0)) : Optional.empty();
1004         }
1005     }
1006 
1007     public static Repository clone(URI from, Path to, boolean isBare) throws IOException {
1008         var cmd = new ArrayList<String>();
1009         cmd.addAll(List.of("git", "clone"));
1010         if (isBare) {
1011             cmd.add("--bare");
1012         }
1013         cmd.addAll(List.of(from.toString(), to.toString()));
1014         try (var p = capture(Path.of("").toAbsolutePath(), cmd)) {
1015             await(p);
1016         }
1017         return new GitRepository(to);
1018     }
1019 
1020     public static Repository mirror(URI from, Path to) throws IOException {
1021         var cwd = Path.of("").toAbsolutePath();
1022         try (var p = capture(cwd, "git", "clone", "--mirror", from.toString(), to.toString())) {
1023             await(p);
1024         }
1025         return new GitRepository(to);
1026     }
1027 
1028     @Override
1029     public void pull() throws IOException {
1030         pull(null, null);
1031     }
1032 
1033     @Override
1034     public void pull(String remote) throws IOException {
1035         pull(remote, null);
1036     }
1037 
1038 
1039     @Override
1040     public void pull(String remote, String refspec) throws IOException {
1041         var cmd = new ArrayList<String>();
1042         cmd.add("git");
1043         cmd.add("pull");
1044         if (remote != null) {
1045             cmd.add(remote);
1046         }
1047         if (refspec != null) {
1048             cmd.add(refspec);
1049         }
1050         try (var p = capture(cmd)) {
1051             await(p);
1052         }
1053     }
1054 
1055     @Override
1056     public boolean contains(Branch b, Hash h) throws IOException {
1057         try (var p = capture("git", "for-each-ref", "--contains", h.hex(), "--format", "%(refname:short)")) {
1058             var res = await(p);
1059             for (var line : res.stdout()) {
1060                 if (line.equals(b.name())) {
1061                     return true;
1062                 }
1063             }
1064         }
1065 
1066         return false;
1067     }
1068 }