package git.lfs.migrate; import com.beust.jcommander.JCommander; import com.beust.jcommander.Parameter; import org.apache.http.HttpStatus; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.ssl.SSLContexts; import org.eclipse.jgit.errors.InvalidPatternException; import org.eclipse.jgit.lib.*; import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.mapdb.DB; import org.mapdb.DBMaker; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ru.bozaro.gitlfs.client.AuthHelper; import ru.bozaro.gitlfs.client.BatchUploader; import ru.bozaro.gitlfs.client.Client; import ru.bozaro.gitlfs.client.auth.AuthProvider; import ru.bozaro.gitlfs.client.auth.BasicAuthProvider; import ru.bozaro.gitlfs.client.exceptions.ForbiddenException; import ru.bozaro.gitlfs.client.exceptions.RequestException; import ru.bozaro.gitlfs.common.data.*; import ru.bozaro.gitlfs.common.data.Error; import java.io.IOException; import java.net.URI; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.security.GeneralSecurityException; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; /** * Entry point. * * @author a.navrotskiy */ public class Main { @NotNull private static final Logger log = LoggerFactory.getLogger(Main.class); public static void main(@NotNull String[] args) throws Exception { final CmdArgs cmd = new CmdArgs(); final JCommander jc = new JCommander(cmd); jc.parse(args); if (cmd.help) { jc.usage(); return; } final long time = System.currentTimeMillis(); final Client client; if (cmd.lfs != null) { client = createClient(new BasicAuthProvider(URI.create(cmd.lfs)), cmd); } else if (cmd.git != null) { client = createClient(AuthHelper.create(cmd.git), cmd); } else { client = null; } if (!checkLfsAuthenticate(client)) { return; } if (cmd.checkLfs) { if (client == null) { log.error("Git LFS server is not defined."); } return; } String[] globs = cmd.globs.toArray(new String[cmd.globs.size()]); if (cmd.globFile != null) { globs = Stream.concat(Arrays.stream(globs), Files.lines(cmd.globFile) .map(String::trim) .filter(s -> !s.isEmpty()) ).toArray(String[]::new); } try { processRepository(cmd.src, cmd.dst, cmd.cache, client, cmd.writeThreads, cmd.uploadThreads, globs); } catch (ExecutionException e) { if (e.getCause() instanceof RequestException) { final RequestException cause = (RequestException) e.getCause(); log.error("HTTP request failure: {}", cause.getRequestInfo()); } throw e; } log.info("Convert time: {}", System.currentTimeMillis() - time); } @NotNull private static Client createClient(@NotNull AuthProvider auth, @NotNull CmdArgs cmd) throws GeneralSecurityException { final HttpClientBuilder httpBuilder = HttpClients.custom(); httpBuilder.setUserAgent("git-lfs-migrate"); if (cmd.noCheckCertificate) { httpBuilder.setSSLHostnameVerifier((hostname, session) -> true); httpBuilder.setSSLContext(SSLContexts.custom() .loadTrustMaterial((chain, authType) -> true) .build()); } return new Client(auth, httpBuilder.build()); } private static boolean checkLfsAuthenticate(@Nullable Client client) throws IOException { if (client == null) return true; final Meta meta = new Meta("0123456789012345678901234567890123456789012345678901234567890123", 42); try { BatchRes response = client.postBatch( new BatchReq(Operation.Upload, Collections.singletonList( meta )) ); if (response.getObjects().size() != 1) { log.error("LFS server: Invalid response for test batch request"); } Error error = response.getObjects().get(0).getError(); if (error != null) { if (error.getCode() == HttpStatus.SC_FORBIDDEN) { log.error("LFS server: Upload access denied"); } else { log.error("LFS server: Upload denied with error: " + error.getMessage()); } } log.info("LFS server: OK"); return true; } catch (ForbiddenException e) { log.error("LFS server: Access denied", e); return false; } catch (IOException e) { log.info("LFS server: Batch API request exception", e); } try { client.getMeta(meta.getOid()); log.error("LFS server: Unsupported batch API"); } catch (IOException ignored) { log.error("LFS server: Invalid base URL"); } return false; } public static void processRepository(@NotNull Path srcPath, @NotNull Path dstPath, @NotNull Path cachePath, @Nullable Client client, int writeThreads, int uploadThreads, @NotNull String... globs) throws IOException, InterruptedException, ExecutionException, InvalidPatternException { removeDirectory(dstPath); Files.createDirectories(dstPath); final Repository srcRepo = new FileRepositoryBuilder() .setMustExist(true) .setGitDir(srcPath.toFile()).build(); final Repository dstRepo = new FileRepositoryBuilder() .setMustExist(false) .setGitDir(dstPath.toFile()).build(); try (DB cache = DBMaker.fileDB(cachePath.resolve("git-lfs-migrate.mapdb").toFile()) .fileMmapEnableIfSupported() .checksumHeaderBypass() .make()) { final GitConverter converter = new GitConverter(cache, dstPath, globs); dstRepo.create(true); // Load all revision list. ConcurrentMap<TaskKey, ObjectId> converted = new ConcurrentHashMap<>(); try (HttpUploader uploader = createHttpUploader(srcRepo, client, uploadThreads)) { log.info("Converting object without dependencies in " + writeThreads + " threads..."); Deque<TaskKey> pass2 = processWithoutDependencies(converter, srcRepo, dstRepo, converted, uploader, writeThreads); log.info("Converting object with dependencies in single thread..."); processSingleThread(converter, srcRepo, dstRepo, converted, uploader, pass2); } log.info("Recreating refs..."); for (Map.Entry<String, Ref> ref : srcRepo.getAllRefs().entrySet()) { RefUpdate refUpdate = dstRepo.updateRef(ref.getKey()); final ObjectId oldId = ref.getValue().getObjectId(); final ObjectId newId = converted.get(new TaskKey(GitConverter.TaskType.Simple, "", oldId)); refUpdate.setNewObjectId(newId); refUpdate.update(); log.info(" convert ref: {} -> {} ({})", oldId.getName(), newId.getName(), ref.getKey()); } } finally { dstRepo.close(); srcRepo.close(); } } @Nullable private static HttpUploader createHttpUploader(@NotNull Repository repository, @Nullable Client client, int uploadThreads) { return client == null ? null : new HttpUploader(repository, client, uploadThreads); } private static void processSingleThread(@NotNull GitConverter converter, @NotNull Repository srcRepo, @NotNull Repository dstRepo, @NotNull Map<TaskKey, ObjectId> converted, @Nullable HttpUploader uploader, @NotNull Deque<TaskKey> queue) throws IOException { try (ProgressReporter reporter = new ProgressReporter("processed", new AtomicLong(queue.size()), null)) { final ObjectInserter inserter = dstRepo.newObjectInserter(); final ObjectReader reader = srcRepo.newObjectReader(); while (!queue.isEmpty()) { final TaskKey taskKey = queue.pop(); if (!converted.containsKey(taskKey)) { boolean taskReady = true; for (TaskKey depend : converter.convertTask(reader, taskKey).depends()) { if (!converted.containsKey(depend)) { queue.add(taskKey); taskReady = false; break; } } if (taskReady) { final ObjectId objectId = converter.convertTask(reader, taskKey).convert(dstRepo, inserter, converted::get, uploader); converted.put(taskKey, objectId); reporter.increment(); } } } } } private static void removeDirectory(@NotNull Path path) throws IOException { if (Files.exists(path)) { Files.walkFileTree(path, new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { Files.delete(file); return FileVisitResult.CONTINUE; } @Override public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { Files.delete(dir); return FileVisitResult.CONTINUE; } }); } } @NotNull private static Deque<TaskKey> processWithoutDependencies(@NotNull GitConverter converter, @NotNull Repository srcRepo, @NotNull Repository dstRepo, @NotNull ConcurrentMap<TaskKey, ObjectId> converted, @Nullable HttpUploader uploader, int threads) throws IOException, InterruptedException { AtomicLong total = new AtomicLong(0); try (ProgressReporter reporter = new ProgressReporter("processed", total, null)) { final Set<TaskKey> checked = new HashSet<>(); final Deque<TaskKey> pass2 = new ArrayDeque<>(); final Deque<TaskKey> queue = new ArrayDeque<>(); // Heads for (Ref ref : srcRepo.getAllRefs().values()) { final TaskKey taskKey = new TaskKey(GitConverter.TaskType.Simple, "", ref.getObjectId()); if (checked.add(taskKey)) { queue.add(taskKey); } } final ExecutorService pool = Executors.newFixedThreadPool(threads); try { final AtomicBoolean done = new AtomicBoolean(false); final List<Future<?>> jobs = new ArrayList<>(threads); final BlockingQueue<TaskKey> channel = new LinkedBlockingQueue<>(); for (int i = 0; i < threads; ++i) { jobs.add(pool.submit(() -> { try { final ObjectInserter inserter = dstRepo.newObjectInserter(); final ObjectReader reader = srcRepo.newObjectReader(); while (!done.get()) { final TaskKey taskKey = channel.take(); if (taskKey.getType() == GitConverter.TaskType.EndMark) break; final ObjectId objectId = converter.convertTask(reader, taskKey).convert(dstRepo, inserter, converted::get, uploader); converted.put(taskKey, objectId); reporter.increment(); } inserter.flush(); } catch (IOException | InterruptedException e) { rethrow(e); } finally { done.set(true); } })); } final ObjectReader reader = srcRepo.newObjectReader(); while (!queue.isEmpty()) { final TaskKey taskKey = queue.pop(); boolean withoutDepends = true; for (TaskKey depend : converter.convertTask(reader, taskKey).depends()) { withoutDepends = false; if (checked.add(depend)) { queue.add(depend); } } if (withoutDepends) { total.incrementAndGet(); channel.add(taskKey); } else { pass2.add(taskKey); } } for (Future<?> ignored : jobs) { channel.add(new TaskKey(GitConverter.TaskType.EndMark, null, ObjectId.zeroId())); } for (Future<?> job : jobs) { try { job.get(); } catch (ExecutionException e) { rethrow(e.getCause()); } } } finally { pool.shutdown(); } return pass2; } } public static void rethrow(final Throwable exception) { class EvilThrower<T extends Throwable> { @SuppressWarnings("unchecked") private void sneakyThrow(Throwable exception) throws T { throw (T) exception; } } new EvilThrower<RuntimeException>().sneakyThrow(exception); } public static class HttpUploader implements GitConverter.Uploader, AutoCloseable { @NotNull private final ThreadLocal<ObjectReader> readers = new ThreadLocal<>(); @NotNull private final ExecutorService pool; @NotNull private final Repository repository; @NotNull private final BatchUploader uploader; @NotNull private final Collection<CompletableFuture<?>> futures = new LinkedBlockingQueue<>(); @NotNull private final AtomicInteger finished = new AtomicInteger(); @NotNull private final AtomicInteger total = new AtomicInteger(); public HttpUploader(@NotNull Repository repository, @NotNull Client client, int threads) { this.pool = Executors.newFixedThreadPool(threads); this.uploader = new BatchUploader(client, pool); this.repository = repository; } @Override public void upload(@NotNull ObjectId oid, @NotNull Meta meta) { total.incrementAndGet(); futures.add(uploader.upload(meta, () -> getReader().open(oid).openStream()).thenAccept((m) -> finished.incrementAndGet())); } @NotNull private ObjectReader getReader() { ObjectReader reader = readers.get(); if (reader == null) { reader = repository.newObjectReader(); readers.set(reader); } return reader; } @Override public void close() throws ExecutionException, InterruptedException { CompletableFuture.allOf(futures.toArray(new CompletableFuture[futures.size()])).get(); pool.shutdown(); } public int getTotal() { return total.get(); } public int getFinished() { return finished.get(); } } public static class ProgressReporter implements AutoCloseable { private static final long DELAY = TimeUnit.SECONDS.toMillis(1); @Nullable private final AtomicLong total; @NotNull private final AtomicLong current = new AtomicLong(0); @NotNull private final AtomicLong lastTime = new AtomicLong(0); @NotNull private final String prefix; @Nullable private final HttpUploader uploader; public ProgressReporter(@NotNull String prefix, @Nullable AtomicLong total, @Nullable HttpUploader uploader) { this.prefix = prefix; this.total = total; this.uploader = uploader; } public void increment() { final long last = current.incrementAndGet(); final long oldTime = lastTime.get(); final long newTime = System.currentTimeMillis(); if (oldTime < newTime - DELAY) { if (lastTime.compareAndSet(oldTime, newTime)) { print(last); } } } @Override public void close() { print(current.get()); } private void print(long current) { String message = " " + prefix + ": " + current + (total != null ? "/" + total.get() : ""); if (uploader != null) { message += ", uploaded: " + uploader.getFinished() + "/" + uploader.getTotal(); } log.info(message); } } public static class CmdArgs { @Parameter(names = {"-s", "--source"}, description = "Source repository", required = true) @NotNull private Path src; @Parameter(names = {"-d", "--destination"}, description = "Destination repository", required = true) @NotNull private Path dst; @Parameter(names = {"-c", "--cache"}, description = "Source repository", required = false) @NotNull private Path cache = FileSystems.getDefault().getPath("."); @Parameter(names = {"-g", "--git"}, description = "GIT repository url (ignored with --lfs parameter)", required = false) @Nullable private String git; @Parameter(names = {"-l", "--lfs"}, description = "LFS server url (can be determinated by --git paramter)", required = false) @Nullable private String lfs; @Parameter(names = {"-t", "--write-threads"}, description = "IO thread count", required = false) private int writeThreads = 2; @Parameter(names = {"-u", "--upload-threads"}, description = "HTTP upload thread count", required = false) private int uploadThreads = 4; @Parameter(names = {"--check-lfs"}, description = "Check LFS server settings and exit") private boolean checkLfs = false; @Parameter(names = {"--no-check-certificate"}, description = "Don't check the server certificate against the available certificate authorities") private boolean noCheckCertificate = false; @Parameter(names = {"--glob-file"}, description = "File containing glob patterns") private Path globFile = null; @Parameter(description = "LFS file glob patterns") @NotNull private List<String> globs = new ArrayList<>(); @Parameter(names = {"-h", "--help"}, description = "Show help", help = true) private boolean help = false; } }