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