1 /*
  2  * Copyright (c) 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.openjdk.convert;
 24 
 25 import org.openjdk.skara.vcs.*;
 26 import org.openjdk.skara.vcs.openjdk.*;
 27 
 28 import java.io.*;
 29 import java.net.URI;
 30 import java.nio.charset.StandardCharsets;
 31 import java.nio.file.*;
 32 import java.time.ZonedDateTime;
 33 import java.time.format.DateTimeFormatter;
 34 import java.util.*;
 35 import java.util.function.Function;
 36 import java.util.logging.Logger;
 37 import java.util.stream.Collectors;
 38 
 39 import static java.lang.Integer.parseInt;
 40 
 41 public class HgToGitConverter implements Converter {
 42     private static class ProcessInfo implements AutoCloseable {
 43         private final java.lang.Process process;
 44         private final List<String> command;
 45         private final Path stdout;
 46         private final Path stderr;
 47         private final CloseAction closeAction;
 48 
 49         @FunctionalInterface
 50         interface CloseAction {
 51             void close() throws IOException;
 52         }
 53 
 54         ProcessInfo(java.lang.Process process, List<String> command, Path stdout, Path stderr, CloseAction closeAction) {
 55             this.process = process;
 56             this.command = command;
 57             this.stdout = stdout;
 58             this.stderr = stderr;
 59             this.closeAction = closeAction;
 60         }
 61 
 62         ProcessInfo(java.lang.Process process, List<String> command, Path stdout, Path stderr) {
 63             this(process, command, stdout, stderr, () -> {});
 64         }
 65 
 66         java.lang.Process process() {
 67             return process;
 68         }
 69 
 70         List<String> command() {
 71             return command;
 72         }
 73 
 74         Path stdout() {
 75             return stdout;
 76         }
 77 
 78         Path stderr() {
 79             return stderr;
 80         }
 81 
 82         @Override
 83         public void close() throws IOException {
 84             if (stdout != null) {
 85                 Files.delete(stdout);
 86             }
 87             if (stderr != null) {
 88                 Files.delete(stderr);
 89             }
 90             closeAction.close();
 91         }
 92     }
 93 
 94     private final byte[] fileBuffer = new byte[2048];
 95     private final Logger log = Logger.getLogger("org.openjdk.skara.vcs.openjdk.convert");
 96 
 97     private final Map<Hash, List<String>> replacements;
 98     private final Map<Hash, Map<String, String>> corrections;
 99     private final Set<Hash> lowercase;
100     private final Set<Hash> punctuated;
101 
102     private final Map<String, String> authorMap;
103     private final Map<String, String> contributorMap;
104     private final Map<String, List<String>> sponsorMap;
105 
106     private final CommitMessageParser parser = new ConverterCommitMessageParser();
107     private int currentMark = 0;
108     private final Map<Hash, Integer> hgHashesToMarks = new HashMap<Hash, Integer>();
109     private final Map<Integer, Hash> marksToHgHashes = new HashMap<Integer, Hash>();
110 
111     public HgToGitConverter(Map<Hash, List<String>> replacements,
112                             Map<Hash, Map<String, String>> corrections,
113                             Set<Hash> lowercase,
114                             Set<Hash> punctuated,
115                             Map<String, String> authorMap,
116                             Map<String, String> contributorMap,
117                             Map<String, List<String>> sponsorMap) {
118         this.replacements = replacements;
119         this.corrections = corrections;
120         this.lowercase = lowercase;
121         this.punctuated = punctuated;
122 
123         this.authorMap = authorMap;
124         this.contributorMap = contributorMap;
125         this.sponsorMap = sponsorMap;
126     }
127 
128     private static Branch convertBranch(Branch branch) {
129         if (branch.name().equals("default")) {
130             return new Branch("master");
131         }
132 
133         return branch;
134     }
135 
136     private static String convertFlags(String flags) {
137         if (flags.contains("x")) {
138             return "100755";
139         }
140 
141         if (flags.contains("l")) {
142             return "120000";
143         }
144 
145         return "100644";
146     }
147 
148     private static String capitalize(String s) {
149         return s.substring(0, 1).toUpperCase() + s.substring(1);
150     }
151 
152     private static String removePunctuation(String s) {
153         return s.endsWith(".") ? s.substring(0, s.length() - 1) : s;
154     }
155 
156     private int nextMark(Hash hgHash) {
157         currentMark++;
158         var next = currentMark;
159         hgHashesToMarks.put(hgHash, next);
160         marksToHgHashes.put(next, hgHash);
161         return next;
162     }
163 
164     private Author convertAuthor(Author from) {
165         var author = authorMap.get(from.name());
166         if (author == null) {
167             throw new RuntimeException("Failed to find author mapping for: " + from.name());
168         }
169         return Author.fromString(author);
170     }
171 
172     private Attribution attribute(List<Author> contributorsFromCommit, Author hgAuthor) {
173         var isSponsored = false;
174         var contributors = new ArrayList<Author>(contributorsFromCommit);
175         if (contributors.size() == 1) {
176             isSponsored = true;
177         } else if (contributors.size() > 1) {
178             // The author has sponsored at least one commit, see if this commit was sponsored.
179             // The commit is sponsored if the author is *not* listed on the "Contributed-by" line.
180 
181             var emails = sponsorMap.get(hgAuthor.name());
182             if (emails == null) {
183                 throw new RuntimeException("Failed to find sponsor mapping for: " + hgAuthor.name());
184             }
185             Author authorAsContributor = null;
186             for (var email : emails) {
187                 for (var contributor : contributors) {
188                     if (contributor.email().equals(email)) {
189                         authorAsContributor = contributor;
190                         break;
191                     }
192                 }
193             }
194             if (authorAsContributor != null) {
195                 contributors.remove(authorAsContributor);
196             } else {
197                 isSponsored = true;
198             }
199         }
200 
201         var originalAuthor = convertAuthor(hgAuthor);
202 
203         Author author = null;
204         if (isSponsored) {
205             author = new Author(contributors.get(0).name(), contributors.get(0).email());
206             contributors.remove(0);
207         } else {
208             author = originalAuthor;
209         }
210         var committer = isSponsored ? originalAuthor : author;
211 
212         return new Attribution(author, committer, contributors);
213     }
214 
215     private List<Author> addContributorNames(List<Author> contributors) {
216         final Function<Author, String> lookup = (Author a) -> {
217             var author = contributorMap.get(a.email());
218             if (author == null) {
219                 throw new RuntimeException("Failed to find contributor mapping for: " + a.email());
220             }
221             return author;
222         };
223         return contributors.stream()
224                            .map(a -> a.name().isEmpty() ? Author.fromString(lookup.apply(a)) : a)
225                            .collect(Collectors.toList());
226     }
227 
228     private static List<String> cleanup(List<String> original, Map<String, String> corrections) {
229         if (corrections == null) {
230             return original;
231         }
232 
233         return original.stream().map(l -> corrections.getOrDefault(l, l)).collect(Collectors.toList());
234     }
235 
236     private String toGitCommitMessage(Hash hash, List<Issue> issues, List<String> summaries, List<Author> contributors, List<String> reviewers, List<String> others) {
237         List<String> body = new ArrayList<String>();
238         body.addAll(summaries.stream().map(HgToGitConverter::capitalize).collect(Collectors.toList()));
239         body.addAll(others);
240 
241         var subject = issues.stream().map(Issue::toString).collect(Collectors.toList());
242         if (subject.size() == 0) {
243             subject = body.subList(0, 1);
244             body = body.subList(1, body.size());
245         }
246 
247         var firstNonNewlineIndex = 0;
248         while (firstNonNewlineIndex < body.size() && body.get(firstNonNewlineIndex).equals("")) {
249             firstNonNewlineIndex++;
250         }
251         body = body.subList(firstNonNewlineIndex, body.size());
252 
253         var sb = new StringBuilder();
254         for (var line : subject) {
255             line = lowercase.contains(hash) ? line : capitalize(line);
256             line = punctuated.contains(hash) ? line : removePunctuation(line);
257             if (line.startsWith("JMC-")) {
258                 line = line.substring(4);
259             }
260             sb.append(line);
261             sb.append("\n");
262         }
263         if ((body.size() + contributors.size() + reviewers.size()) > 0) {
264             sb.append("\n");
265         }
266 
267         var hasPrintedNonNewline = false;
268         for (var line : body) {
269             // Remove any number of initial empty lines
270             if (!hasPrintedNonNewline && line.equals("")) {
271                 continue;
272             }
273             sb.append(line);
274             sb.append("\n");
275             hasPrintedNonNewline = true;
276         }
277         if (body.size() > 0) {
278             sb.append("\n");
279         }
280 
281         for (var contributor : contributors) {
282             sb.append("Co-authored-by: ");
283             sb.append(contributor.toString());
284             sb.append("\n");
285         }
286 
287         if (reviewers.size() > 0) {
288             sb.append("Reviewed-by: ");
289             sb.append(String.join(", ", reviewers));
290             sb.append("\n");
291         }
292 
293         return sb.toString();
294     }
295 
296     private GitCommitMetadata convertMetadata(Hash hgHash,
297                                               Branch hgBranch,
298                                               Author hgAuthor,
299                                               List<Hash> hgParentHashes,
300                                               ZonedDateTime hgDate,
301                                               List<String> hgCommitMessage) {
302         var shortHash = new Hash(hgHash.hex().substring(0, 12));
303 
304         hgCommitMessage = replacements.getOrDefault(shortHash, hgCommitMessage);
305         hgCommitMessage = cleanup(hgCommitMessage, corrections.get(shortHash));
306 
307         var commitMessage = parser.parse(hgCommitMessage);
308         var hgContributors = addContributorNames(commitMessage.contributors());
309 
310         var attribution = attribute(hgContributors, hgAuthor);
311         var gitAuthor = attribution.author();
312         var gitCommitter = attribution.committer();
313         var gitMessage = toGitCommitMessage(shortHash,
314                                             commitMessage.issues(),
315                                             commitMessage.summaries(),
316                                             attribution.contributors(),
317                                             commitMessage.reviewers(),
318                                             commitMessage.additional());
319 
320         var gitMark = nextMark(hgHash);
321         var gitParentMarks = hgParentHashes.stream().map(hgHashesToMarks::get).collect(Collectors.toList());
322 
323         var gitBranch = convertBranch(hgBranch);
324         var gitDate = hgDate; // no conversion needed
325 
326         return new GitCommitMetadata(gitMark, gitParentMarks, gitAuthor, gitCommitter, gitBranch, gitDate, gitMessage);
327     }
328 
329     private List<Hash> convertCommits(Pipe pipe) throws IOException {
330         var tagCommits = new ArrayList<Hash>();
331         while (pipe.read() != -1) {
332             pipe.readln(); // skip delimiter
333             var hash = new Hash(pipe.readln());
334             log.fine("Converting: " + hash.hex());
335             pipe.readln(); // skip revision number
336             var branch = new Branch(pipe.readln());
337             log.finer("Branch: " + branch.name());
338 
339             var parents = pipe.readln();
340             log.finer("Parents: " + parents);
341             var parentHashes = Arrays.asList(parents.split(" "))
342                                      .stream()
343                                      .map(Hash::new)
344                                      .collect(Collectors.toList());
345             if (parentHashes.size() == 1 && parentHashes.get(0).equals(new Hash("0".repeat(40)))) {
346                 parentHashes = new ArrayList<Hash>();
347             }
348             pipe.readln(); // skip parent revisions
349 
350             var author = Author.fromString(pipe.readln());
351             log.finer("Author: " + author.toString());
352 
353             var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd H:m:sZ");
354             var date = ZonedDateTime.parse(pipe.readln(), formatter);
355             log.finer("Date: " + date.toString());
356 
357             var messageSize = parseInt(pipe.readln());
358             var messageBuffer = pipe.read(messageSize);
359             var hgMessage = new String(messageBuffer, 0, messageSize, StandardCharsets.UTF_8);
360             log.finest("Message: " + hgMessage);
361 
362             var metadata = convertMetadata(hash,
363                                            branch,
364                                            author,
365                                            parentHashes,
366                                            date,
367                                            Arrays.asList(hgMessage.split("\n")));
368 
369             pipe.print("commit refs/heads/");
370             pipe.println(metadata.branch().name());
371 
372             pipe.print("mark :");
373             pipe.println(metadata.mark());
374 
375             var epoch = metadata.date().toEpochSecond();
376             var offset = metadata.date().format(DateTimeFormatter.ofPattern("xx"));
377 
378             pipe.print("author ");
379             pipe.print(metadata.author().name());
380             pipe.print(" <");
381             pipe.print(metadata.author().email());
382             pipe.print("> ");
383             pipe.print(epoch);
384             pipe.print(" ");
385             pipe.println(offset);
386 
387             pipe.print("committer ");
388             pipe.print(metadata.committer().name());
389             pipe.print(" <");
390             pipe.print(metadata.committer().email());
391             pipe.print("> ");
392             pipe.print(epoch);
393             pipe.print(" ");
394             pipe.println(offset);
395 
396             pipe.print("data ");
397 
398             var gitMessage = metadata.message().getBytes(StandardCharsets.UTF_8);
399             pipe.println(gitMessage.length);
400             pipe.print(gitMessage);
401 
402             if (metadata.parents().size() > 0) {
403                 pipe.print("from :");
404                 pipe.println(metadata.parents().get(0));
405             }
406             if (metadata.parents().size() > 1) {
407                 pipe.print("merge :");
408                 pipe.println(metadata.parents().get(1));
409             }
410 
411             // Stream the file content
412             var numModified = parseInt(pipe.readln());
413             var numAdded = parseInt(pipe.readln());
414             var numRemoved = parseInt(pipe.readln());
415 
416             for (var i = 0; i < (numAdded + numModified); i++) {
417                 var filename = pipe.readln();
418                 var flags = pipe.readln();
419 
420                 if (filename.equals(".hgtags") && parentHashes.size() == 1) {
421                     tagCommits.add(hash);
422                 }
423 
424                 log.finest("M " + filename);
425                 pipe.print("M ");
426                 pipe.print(convertFlags(flags));
427                 pipe.print(" inline ");
428                 pipe.println(filename);
429 
430                 var numBytes = parseInt(pipe.readln());
431                 pipe.print("data ");
432                 pipe.println(numBytes);
433 
434                 var leftToRead = numBytes;
435                 while (leftToRead != 0) {
436                     var numRead = pipe.read(fileBuffer, 0, Math.min(fileBuffer.length, leftToRead));
437                     if (numRead == -1) {
438                         throw new IOException("Unexpected end of input");
439                     }
440                     pipe.print(fileBuffer, 0, numRead);
441                     leftToRead -= numRead;
442                 }
443             }
444 
445             for (var i = 0; i < numRemoved; i++) {
446                 var filename = pipe.readln();
447                 log.finest("D " + filename);
448                 pipe.print("D ");
449                 pipe.println(filename);
450             }
451         }
452 
453 
454         return tagCommits;
455     }
456 
457     private void convertTags(Pipe pipe, List<Hash> tagCommits, ReadOnlyRepository hgRepo) throws IOException {
458         var tags = new HashMap<String, Commit>();
459         for (var tagHash : tagCommits) {
460             log.fine("Inspecting tag commit " + tagHash.toString());
461             var commit = hgRepo.lookup(tagHash).orElseThrow(() -> new IOException("Could not find commit " + tagHash));
462             var diff = commit.parentDiffs().get(0); // convert never returns merge commits
463             for (var patch : diff.patches()) {
464                 var target = patch.target().path();
465                 if (target.isPresent() && target.get().equals(Path.of(".hgtags"))) {
466                     for (var hunk : patch.asTextualPatch().hunks()) {
467                         for (var line : hunk.target().lines()) {
468                             if (line.isEmpty()) {
469                                 continue;
470                             }
471                             var parts = line.split(" ");
472                             var hash = parts[0];
473                             var tag = parts[1];
474                             if (hash.equals("0".repeat(40))) {
475                                 tags.remove(tag);
476                             } else {
477                                 tags.put(tag, commit);
478                             }
479                         }
480                     }
481                 }
482             }
483         }
484 
485         for (var tag : hgRepo.tags()) {
486             if (tags.containsKey(tag.name())) {
487                 var commit = tags.get(tag.name());
488 
489                 log.fine("Converting tag " + tag.name());
490                 pipe.print("tag ");
491                 pipe.println(tag.name());
492                 pipe.print("from :");
493                 pipe.println(hgHashesToMarks.get(hgRepo.lookup(tag).get().hash()));
494 
495                 pipe.print("tagger ");
496                 var author = convertAuthor(commit.author());
497                 pipe.print(author.name());
498                 pipe.print(" <");
499                 pipe.print(author.email());
500                 pipe.print("> ");
501                 var epoch = commit.date().toEpochSecond();
502                 var offset = commit.date().format(DateTimeFormatter.ofPattern("xx"));
503                 pipe.print(epoch);
504                 pipe.print(" ");
505                 pipe.println(offset);
506 
507                 pipe.print("data ");
508                 var message = String.join("\n", commit.message());
509                 var bytes = message.getBytes(StandardCharsets.UTF_8);
510                 pipe.println(bytes.length);
511                 pipe.print(bytes);
512             }
513         }
514     }
515 
516     private List<Mark> readMarks(Path p) throws IOException {
517         var marks = new ArrayList<Mark>();
518         try (var reader = Files.newBufferedReader(p)) {
519             for (var line = reader.readLine(); line != null; line = reader.readLine()) {
520                 var parts = line.split(" ");
521                 var mark = parseInt(parts[0].substring(1));
522                 var gitHash = new Hash(parts[1]);
523                 var hgHash = marksToHgHashes.get(mark);
524                 log.finest("parsed mark " + mark + ", hg: " + hgHash.hex() + ", git: " + gitHash.hex());
525                 marks.add(new Mark(mark, hgHash, gitHash));
526             }
527         }
528         return marks;
529     }
530 
531     private Path writeMarks(List<Mark> marks) throws IOException {
532         var gitMarks = Files.createTempFile("git", ".marks.txt");
533         try (var writer = Files.newBufferedWriter(gitMarks)) {
534             for (var mark : marks) {
535                 writer.write(":");
536                 writer.write(Integer.toString(mark.key()));
537                 writer.write(" ");
538                 writer.write(mark.git().hex());
539                 writer.newLine();
540 
541                 marksToHgHashes.put(mark.key(), mark.hg());
542                 hgHashesToMarks.put(mark.hg(), mark.key());
543             }
544         }
545         return gitMarks;
546     }
547 
548     private ProcessInfo dump(ReadOnlyRepository repo) throws IOException {
549         var ext = Files.createTempFile("ext", ".py");
550         Files.copy(this.getClass().getResourceAsStream("/ext.py"), ext, StandardCopyOption.REPLACE_EXISTING);
551 
552         var command = List.of("hg", "--config", "extensions.dump=" + ext.toAbsolutePath().toString(), "dump");
553         var pb = new ProcessBuilder(command);
554         pb.environment().put("HGRCPATH", "");
555         pb.environment().put("HGPLAIN", "");
556         pb.directory(repo.root().toFile());
557 
558         var stderr = Files.createTempFile("dump", ".stderr.txt");
559         pb.redirectError(stderr.toFile());
560         log.finer("hg dump stderr: " + stderr.toString());
561 
562         log.finer("Starting '" + String.join(" ", command) + "'");
563         return new ProcessInfo(pb.start(), command, null, stderr, () -> Files.delete(ext));
564     }
565 
566     private ProcessInfo pull(Repository repo, URI source) throws IOException {
567         var ext = Files.createTempFile("ext", ".py");
568         var extStream = getClass().getResourceAsStream("/ext.py");
569         if (extStream == null) {
570             // Used when running outside of the module path (such as from an IDE)
571             var classPath = Path.of(getClass().getProtectionDomain().getCodeSource().getLocation().getPath());
572             var extPath = classPath.getParent().resolve("resources").resolve("ext.py");
573             extStream = new FileInputStream(extPath.toFile());
574         }
575         Files.copy(extStream, ext, StandardCopyOption.REPLACE_EXISTING);
576 
577         var hook = "hooks.pretxnclose=python:" + ext.toAbsolutePath().toString() + ":pretxnclose";
578         var command = List.of("hg", "--config", hook, "pull", "--quiet", source.toString());
579         var pb = new ProcessBuilder(command);
580         pb.environment().put("HGRCPATH", "");
581         pb.environment().put("HGPLAIN", "");
582         pb.directory(repo.root().toFile());
583 
584         var stderr = Files.createTempFile("pull", ".stderr.txt");
585         pb.redirectError(stderr.toFile());
586 
587         log.finer("Starting '" + String.join(" ", command) + "'");
588         return new ProcessInfo(pb.start(), command, null, stderr, () -> Files.delete(ext));
589     }
590 
591     private ProcessInfo fastImport(ReadOnlyRepository repo) throws IOException {
592         var command = List.of("git", "fast-import");
593         var pb = new ProcessBuilder(command);
594         pb.directory(repo.root().toFile());
595 
596         var stdout = Files.createTempFile("fast-import", ".stdout.txt");
597         pb.redirectOutput(stdout.toFile());
598 
599         var stderr = Files.createTempFile("fast-import", ".stderr.txt");
600         pb.redirectError(stderr.toFile());
601 
602         log.finer("Starting '" + String.join(" ", command) + "'");
603         return new ProcessInfo(pb.start(), command, stdout, stderr);
604     }
605 
606     private void await(ProcessInfo p) throws IOException {
607         try {
608             int res = p.process().waitFor();
609             if (res != 0) {
610                 var msg = String.join(" ", p.command()) + " exited with status " + res;
611                 log.severe(msg);
612                 throw new IOException(msg);
613             }
614         } catch (InterruptedException e) {
615             throw new IOException(e);
616         }
617     }
618 
619     private void convert(ProcessInfo hg, ProcessInfo git, ReadOnlyRepository hgRepo, Path marks) throws IOException {
620         var pipe = new Pipe(hg.process().getInputStream(), git.process().getOutputStream(), 512);
621 
622         pipe.println("feature done");
623         pipe.println("feature import-marks-if-exists=" + marks.toAbsolutePath().toString());
624         pipe.println("feature export-marks=" + marks.toAbsolutePath().toString());
625 
626         var tagCommits = convertCommits(pipe);
627         convertTags(pipe, tagCommits, hgRepo);
628 
629         pipe.println("done");
630     }
631 
632     private void log(ProcessInfo hg, ProcessInfo git, Path gitRoot) throws IOException {
633         if (Files.exists(hg.stderr())) {
634             var content = Files.readString(hg.stderr());
635             if (!content.isEmpty()) {
636                 log.warning("'" + String.join(" ", hg.command()) + "' [stderr]: " + content);
637             }
638         }
639 
640         if (Files.exists(git.stdout())) {
641             var content = Files.readString(git.stdout());
642             if (!content.isEmpty()) {
643                 log.warning("'" + String.join(" ", git.command()) + "' [stdout]: " + content);
644             }
645         }
646         if (Files.exists(git.stderr())) {
647             var content = Files.readString(git.stderr());
648             if (!content.isEmpty()) {
649                 log.warning("'" + String.join(" ", git.command()) + "' [stderr]: " + content);
650             }
651         }
652 
653         if (Files.isDirectory(gitRoot)) {
654             for (var path : Files.walk(gitRoot).collect(Collectors.toList())) {
655                 if (path.getFileName().toString().startsWith("fast_import_crash")) {
656                     log.warning(Files.readString(path));
657                 }
658             }
659         }
660     }
661 
662     public List<Mark> convert(ReadOnlyRepository hgRepo, Repository gitRepo) throws IOException {
663         try (var hg = dump(hgRepo);
664              var git = fastImport(gitRepo)) {
665             try {
666                 var gitMarks = Files.createTempFile("git", ".marks.txt");
667                 convert(hg, git, hgRepo, gitMarks);
668 
669                 await(git);
670                 await(hg);
671 
672                 var ret = readMarks(gitMarks);
673                 Files.delete(gitMarks);
674                 return ret;
675             } catch (IOException e) {
676                 log(hg, git, gitRepo.root());
677                 throw e;
678             }
679         }
680     }
681 
682     public List<Mark> pull(Repository hgRepo, URI source, Repository gitRepo, List<Mark> marks) throws IOException {
683         try (var hg = pull(hgRepo, source);
684              var git = fastImport(gitRepo)) {
685             try {
686                 for (var mark : marks) {
687                     hgHashesToMarks.put(mark.hg(), mark.key());
688                     marksToHgHashes.put(mark.key(), mark.hg());
689                     currentMark = Math.max(mark.key(), currentMark);
690                 }
691                 var gitMarks = writeMarks(marks);
692                 convert(hg, git, hgRepo, gitMarks);
693 
694                 await(git);
695                 await(hg);
696 
697                 var ret = readMarks(gitMarks);
698                 Files.delete(gitMarks);
699                 return ret;
700             } catch (IOException e) {
701                 log(hg, git, gitRepo.root());
702                 throw e;
703             }
704         }
705     }
706 }