/* Copyright (c) 2012-2014 Boundless and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Distribution License v1.0 * which accompanies this distribution, and is available at * https://www.eclipse.org/org/documents/edl-v10.html * * Contributors: * Gabriel Roldan (Boundless) - initial implementation */ package org.locationtech.geogig.cli; import static com.google.common.base.Preconditions.checkNotNull; import java.io.IOException; import java.lang.annotation.Annotation; import java.text.NumberFormat; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.ServiceLoader; import java.util.TreeSet; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; import jline.console.ConsoleReader; import jline.console.CursorBuffer; import org.locationtech.geogig.api.Context; import org.locationtech.geogig.api.DefaultPlatform; import org.locationtech.geogig.api.DefaultProgressListener; import org.locationtech.geogig.api.GeoGIG; import org.locationtech.geogig.api.GlobalContextBuilder; import org.locationtech.geogig.api.Platform; import org.locationtech.geogig.api.ProgressListener; import org.locationtech.geogig.api.hooks.CannotRunGeogigOperationException; import org.locationtech.geogig.api.plumbing.ResolveGeogigDir; import org.locationtech.geogig.api.porcelain.ConfigException; import org.locationtech.geogig.api.porcelain.ConfigGet; import org.locationtech.geogig.cli.annotation.ObjectDatabaseReadOnly; import org.locationtech.geogig.cli.annotation.ReadOnly; import org.locationtech.geogig.cli.annotation.RemotesReadOnly; import org.locationtech.geogig.cli.annotation.RequiresRepository; import org.locationtech.geogig.cli.annotation.StagingDatabaseReadOnly; import org.locationtech.geogig.repository.Hints; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.beust.jcommander.JCommander; import com.beust.jcommander.ParameterException; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Objects; import com.google.common.base.Optional; import com.google.common.base.Predicate; import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.base.Throwables; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.inject.Binding; import com.google.inject.Guice; import com.google.inject.Key; import com.google.inject.Module; //import org.python.core.exceptions; /** * Command Line Interface for geogig. * <p> * Looks up and executes {@link CLICommand} implementations provided by any {@link Guice} * {@link Module} that implements {@link CLIModule} declared in any classpath's * {@code META-INF/services/com.google.inject.Module} file. */ public class GeogigCLI { private static final Logger LOGGER = LoggerFactory.getLogger(GeogigCLI.class); static { GlobalContextBuilder.builder = new CLIContextBuilder(); } private static final com.google.inject.Injector commandsInjector; static { Iterable<CLIModule> plugins = ServiceLoader.load(CLIModule.class); commandsInjector = Guice.createInjector(plugins); } private Context geogigInjector; private Platform platform; private GeoGIG geogig; private final GeoGIG providedGeogig; private final ConsoleReader consoleReader; protected ProgressListener progressListener; private boolean exitOnFinish = true; private static final Hints READ_WRITE = Hints.readWrite(); private Hints hints = READ_WRITE; private boolean progressListenerDisabled; /** * Construct a GeogigCLI with the given console reader. * * @param consoleReader */ public GeogigCLI(final ConsoleReader consoleReader) { this(null, consoleReader); } /** * Constructor to use the provided {@code GeoGIG} instance and never try to close it. */ public GeogigCLI(final GeoGIG geogig, final ConsoleReader consoleReader) { this.consoleReader = consoleReader; this.platform = new DefaultPlatform(); this.providedGeogig = geogig; } /** * @return the platform being used by the geogig command line interface. * @see Platform */ public Platform getPlatform() { return platform; } /** * Sets the platform for the command line interface to use. * * @param platform the platform to use * @see Platform */ public void setPlatform(Platform platform) { checkNotNull(platform); this.platform = platform; } public void disableProgressListener() { this.progressListenerDisabled = true; } /** * Provides a GeoGIG facade configured for the current repository if inside a repository, * {@code null} otherwise. * <p> * Note the repository is lazily loaded and cached afterwards to simplify the execution of * commands or command options that do not need a live repository. * * @return the GeoGIG facade associated with the current repository, or {@code null} if there's * no repository in the current {@link Platform#pwd() working directory} * @see ResolveGeogigDir */ @Nullable public synchronized GeoGIG getGeogig() { if (providedGeogig != null) { return providedGeogig; } if (geogig == null) { GeoGIG geogig = loadRepository(); setGeogig(geogig); } return geogig; } @VisibleForTesting public synchronized GeoGIG getGeogig(Hints hints) { close(); GeoGIG geogig = loadRepository(hints); setGeogig(geogig); return geogig; } /** * Gives the command line interface a GeoGIG facade to use. * * @param geogig */ public void setGeogig(@Nullable GeoGIG geogig) { this.geogig = geogig; } /** * Sets flag controlling whether the cli will call {@link System#exit(int)} when done running * the command. * <p> * Commands should call this method only in cases where the starts a server or creates * additional threads. * </p> * * @param exit <tt>true</tt> will cause the cli to exit. */ public void setExitOnFinish(boolean exit) { this.exitOnFinish = exit; } /** * Returns flag controlling whether cli will exit on completion. * * @see {@link #setExitOnFinish(boolean)} */ public boolean isExitOnFinish() { return exitOnFinish; } /** * Loads the repository _if_ inside a geogig repository and returns a configured {@link GeoGIG} * facade. * * @return a geogig for the current repository or {@code null} if not inside a geogig repository * directory. */ @Nullable private GeoGIG loadRepository() { return loadRepository(this.hints); } @Nullable private GeoGIG loadRepository(Hints hints) { GeoGIG geogig = newGeoGIG(hints); if (geogig.command(ResolveGeogigDir.class).call().isPresent()) { geogig.getRepository(); return geogig; } geogig.close(); return null; } /** * Constructs and returns a new read-write geogig facade, which will not be managed by this * GeogigCLI instance, so the calling code is responsible for closing/disposing it after usage * * @return the constructed GeoGIG. */ public GeoGIG newGeoGIG() { return newGeoGIG(Hints.readWrite()); } public GeoGIG newGeoGIG(Hints hints) { Context inj = newGeogigInjector(hints); GeoGIG geogig = new GeoGIG(inj, platform.pwd()); try { geogig.getRepository(); } catch (Exception e) { throw Throwables.propagate(e); } return geogig; } /** * @return the Guice injector being used by the command line interface. If one hasn't been made, * it will be created. */ public Context getGeogigInjector() { return getGeogigInjector(this.hints); } private Context getGeogigInjector(Hints hints) { if (this.geogigInjector == null || !Objects.equal(this.hints, hints)) { // System.err.println("Injector hints: " + hints); geogigInjector = newGeogigInjector(hints); } return geogigInjector; } private Context newGeogigInjector(Hints hints) { Context geogigInjector = GlobalContextBuilder.builder.build(hints); return geogigInjector; } /** * @return the console reader being used by the command line interface. */ public ConsoleReader getConsole() { return consoleReader; } /** * Closes the GeoGIG facade if it exists. */ public synchronized void close() { if (providedGeogig != null) { return; } if (geogig != null) { geogig.close(); geogig = null; } this.hints = READ_WRITE; this.geogigInjector = null; } /** * @return true if a command is being ran */ public synchronized boolean isRunning() { return geogig != null; } /** * Entry point for the command line interface. * * @param args */ public static void main(String[] args) { Logging.tryConfigureLogging(); ConsoleReader consoleReader; try { consoleReader = new ConsoleReader(System.in, System.out); // needed for CTRL+C not to let the console broken consoleReader.getTerminal().setEchoEnabled(true); } catch (Exception e) { throw Throwables.propagate(e); } final GeogigCLI cli = new GeogigCLI(consoleReader); addShutdownHook(cli); int exitCode = cli.execute(args); try { cli.close(); } finally { try { consoleReader.getTerminal().restore(); } catch (Exception e) { LOGGER.error(e.getMessage(), e); exitCode = -1; } consoleReader.shutdown(); } if (exitCode != 0 || cli.isExitOnFinish()) { System.exit(exitCode); } } /** * Finds all commands that are bound do the command injector. * * @return a collection of keys, one for each command */ private Collection<Key<?>> findCommands() { Map<Key<?>, Binding<?>> commands = commandsInjector.getBindings(); return commands.keySet(); } public JCommander newCommandParser() { JCommander jc = new JCommander(this); jc.setProgramName("geogig"); for (Key<?> cmd : findCommands()) { Object obj = commandsInjector.getInstance(cmd); if (obj instanceof CLICommand || obj instanceof CLICommandExtension) { jc.addCommand(obj); } } return jc; } @VisibleForTesting public Exception exception; /** * Processes a command, catching any exceptions and printing their messages to the console. * * @param args * @return 0 for normal exit, -1 if there was an exception. */ public int execute(String... args) { exception = null; String consoleMessage = null; boolean printError = true; try { executeInternal(args); return 0; } catch (ParameterException paramParseException) { exception = paramParseException; consoleMessage = paramParseException.getMessage() + ". See geogig --help"; } catch (InvalidParameterException paramValidationError) { exception = paramValidationError; consoleMessage = paramValidationError.getMessage(); } catch (CannotRunGeogigOperationException cannotRun) { consoleMessage = cannotRun.getMessage(); } catch (CommandFailedException cmdFailed) { exception = cmdFailed; if (null == cmdFailed.getMessage()) { // this is intentional, see the javadoc for CommandFailedException printError = false; } else { LOGGER.error(consoleMessage, cmdFailed.getCause()); consoleMessage = cmdFailed.getMessage(); } } catch (RuntimeException e) { exception = e; // e.printStackTrace(); consoleMessage = String.format( "An unhandled error occurred: %s. See the log for more details.", e.getMessage()); LOGGER.error(consoleMessage, e); } catch (IOException ioe) { exception = ioe; // can't write to the console, see the javadocs for CLICommand.run(). LOGGER.error( "An IOException was caught, should only happen if an error occurred while writing to the console", ioe); } finally { // close after executing a command for the next one to reopen with its own hints and not // to keep the db's open for write meanwhile close(); } if (printError) { try { consoleReader.println(Optional.fromNullable(consoleMessage).or("Unknown Error")); consoleReader.flush(); } catch (IOException e) { LOGGER.error("Error writing to the console. Original error: {}", consoleMessage, e); } } return -1; } /** * Executes a command. * * @param args * @throws exceptions thrown by the executed commands. */ private void executeInternal(String... args) throws ParameterException, CommandFailedException, IOException, CannotRunGeogigOperationException { JCommander mainCommander = newCommandParser(); if (null == args || args.length == 0) { printShortCommandList(mainCommander); return; } { args = unalias(args); final String commandName = args[0]; JCommander commandParser = mainCommander.getCommands().get(commandName); if (commandParser == null) { consoleReader.println(args[0] + " is not a geogig command. See geogig --help."); // check for similar commands Map<String, JCommander> candidates = spellCheck(mainCommander.getCommands(), commandName); if (!candidates.isEmpty()) { String msg = candidates.size() == 1 ? "Did you mean this?" : "Did you mean one of these?"; consoleReader.println(); consoleReader.println(msg); for (String name : candidates.keySet()) { consoleReader.println("\t" + name); } } consoleReader.flush(); throw new CommandFailedException(String.format("'%s' is not a command.", commandName)); } Object object = commandParser.getObjects().get(0); if (object instanceof CLICommandExtension) { args = Arrays.asList(args).subList(1, args.length) .toArray(new String[args.length - 1]); mainCommander = ((CLICommandExtension) object).getCommandParser(); if (Lists.newArrayList(args).contains("--help")) { printUsage(mainCommander); return; } } } mainCommander.parse(args); final String parsedCommand = mainCommander.getParsedCommand(); if (null == parsedCommand) { if (mainCommander.getObjects().size() == 0) { printUsage(mainCommander); } else if (mainCommander.getObjects().get(0) instanceof CLICommandExtension) { CLICommandExtension extension = (CLICommandExtension) mainCommander.getObjects() .get(0); printUsage(extension.getCommandParser()); } else { printUsage(mainCommander); throw new CommandFailedException(); } } else { JCommander jCommander = mainCommander.getCommands().get(parsedCommand); List<Object> objects = jCommander.getObjects(); CLICommand cliCommand = (CLICommand) objects.get(0); Class<? extends CLICommand> cmdClass = cliCommand.getClass(); if (cliCommand instanceof AbstractCommand && ((AbstractCommand) cliCommand).help) { ((AbstractCommand) cliCommand).printUsage(this); getConsole().flush(); return; } Hints hints = gatherHints(cmdClass); this.hints = hints; if (cmdClass.isAnnotationPresent(RequiresRepository.class) && cmdClass.getAnnotation(RequiresRepository.class).value()) { String workingDir; Platform platform = getPlatform(); if (platform == null || platform.pwd() == null) { workingDir = "Couln't determine working directory."; } else { workingDir = platform.pwd().getAbsolutePath(); } if (getGeogig() == null) { throw new CommandFailedException("Not in a geogig repository: " + workingDir); } } cliCommand.run(this); getConsole().flush(); } } /** * This method should be used instead of {@link JCommander#usage()} so the help string is * printed to the cli's {@link #getConsole() console} (and hence to wherever its output is sent) * instead of directly to {@code System.out} */ public void printUsage(JCommander mainCommander) { StringBuilder out = new StringBuilder(); mainCommander.usage(out); ConsoleReader console = getConsole(); try { console.println(out.toString()); console.flush(); } catch (IOException e) { throw Throwables.propagate(e); } } private Hints gatherHints(Class<? extends CLICommand> cmdClass) { Hints hints = new Hints(); checkAnnotationHint(cmdClass, ReadOnly.class, Hints.OBJECTS_READ_ONLY, hints); checkAnnotationHint(cmdClass, ReadOnly.class, Hints.STAGING_READ_ONLY, hints); checkAnnotationHint(cmdClass, ObjectDatabaseReadOnly.class, Hints.OBJECTS_READ_ONLY, hints); checkAnnotationHint(cmdClass, StagingDatabaseReadOnly.class, Hints.STAGING_READ_ONLY, hints); checkAnnotationHint(cmdClass, RemotesReadOnly.class, Hints.REMOTES_READ_ONLY, hints); return hints; } private void checkAnnotationHint(Class<? extends CLICommand> cmdClass, Class<? extends Annotation> annotation, String key, Hints hints) { if (cmdClass.isAnnotationPresent(annotation)) { hints.set(key, Boolean.TRUE); } } /** * If the passed arguments contains an alias, it replaces it by the full command corresponding * to that alias and returns anew set of arguments * * IF not, it returns the passed arguments * * @param args * @return */ private String[] unalias(String[] args) { final String aliasedCommand = args[0]; String configParam = "alias." + aliasedCommand; boolean closeGeogig = false; GeoGIG geogig = this.providedGeogig == null ? this.geogig : this.providedGeogig; if (geogig == null) { // in case the repo is not initialized yet closeGeogig = true; geogig = newGeoGIG(Hints.readOnly()); } try { Optional<String> unaliased = Optional.absent(); if (geogig.command(ResolveGeogigDir.class).call().isPresent()) { unaliased = geogig.command(ConfigGet.class).setName(configParam).call(); } if (!unaliased.isPresent()) { unaliased = geogig.command(ConfigGet.class).setGlobal(true).setName(configParam) .call(); } if (!unaliased.isPresent()) { return args; } Iterable<String> tokens = Splitter.on(" ").split(unaliased.get()); List<String> allArgs = Lists.newArrayList(tokens); allArgs.addAll(Lists.newArrayList(Arrays.copyOfRange(args, 1, args.length))); return allArgs.toArray(new String[0]); } catch (ConfigException e) { return args; } finally { if (closeGeogig) { geogig.close(); } } } /** * Return all commands with a command name at a levenshtein distance of less than 3, as * potential candidates for a mistyped command * * @param commands the list of all available commands * @param commandName the command name * @return a map filtered according to distance between command names */ private Map<String, JCommander> spellCheck(Map<String, JCommander> commands, final String commandName) { Map<String, JCommander> candidates = Maps.filterEntries(commands, new Predicate<Map.Entry<String, JCommander>>() { @Override public boolean apply(@Nullable Entry<String, JCommander> entry) { char[] s1 = entry.getKey().toCharArray(); char[] s2 = commandName.toCharArray(); int[] prev = new int[s2.length + 1]; for (int j = 0; j < s2.length + 1; j++) { prev[j] = j; } for (int i = 1; i < s1.length + 1; i++) { int[] curr = new int[s2.length + 1]; curr[0] = i; for (int j = 1; j < s2.length + 1; j++) { int d1 = prev[j] + 1; int d2 = curr[j - 1] + 1; int d3 = prev[j - 1]; if (s1[i - 1] != s2[j - 1]) { d3 += 1; } curr[j] = Math.min(Math.min(d1, d2), d3); } prev = curr; } return prev[s2.length] < 3; } }); return candidates; } /** * This prints out only porcelain commands * * @param mainCommander * * @throws IOException */ public void printShortCommandList(JCommander mainCommander) { TreeSet<String> commandNames = Sets.newTreeSet(); int longestCommandLenght = 0; // do this to ignore aliases for (String name : mainCommander.getCommands().keySet()) { JCommander command = mainCommander.getCommands().get(name); Class<? extends Object> clazz = command.getObjects().get(0).getClass(); String packageName = clazz.getPackage().getName(); if (!packageName.startsWith("org.locationtech.geogig.cli.plumbing")) { commandNames.add(name); longestCommandLenght = Math.max(longestCommandLenght, name.length()); } } ConsoleReader console = getConsole(); try { console.println("usage: geogig <command> [<args>]"); console.println(); console.println("The most commonly used geogig commands are:"); for (String cmd : commandNames) { console.print(Strings.padEnd(cmd, longestCommandLenght, ' ')); console.print("\t"); console.println(mainCommander.getCommandDescription(cmd)); } console.flush(); } catch (IOException e) { throw Throwables.propagate(e); } } /** * This prints out all commands, including plumbing ones, without description * * @param mainCommander * @throws IOException */ public void printCommandList(JCommander mainCommander) { TreeSet<String> commandNames = Sets.newTreeSet(); int longestCommandLenght = 0; // do this to ignore aliases for (String name : mainCommander.getCommands().keySet()) { commandNames.add(name); longestCommandLenght = Math.max(longestCommandLenght, name.length()); } ConsoleReader console = getConsole(); try { console.println("usage: geogig <command> [<args>]"); console.println(); int i = 0; for (String cmd : commandNames) { console.print(Strings.padEnd(cmd, longestCommandLenght, ' ')); i++; if (i % 3 == 0) { console.println(); } else { console.print("\t"); } } console.flush(); } catch (IOException e) { throw Throwables.propagate(e); } } /** * /** * * @return the ProgressListener for the command line interface. If it doesn't exist, a new one * will be constructed. * @see ProgressListener */ public synchronized ProgressListener getProgressListener() { if (this.progressListener == null) { if (progressListenerDisabled) { this.progressListener = new DefaultProgressListener(); return this.progressListener; } this.progressListener = new DefaultProgressListener() { private final Platform platform = getPlatform(); private final ConsoleReader console = getConsole(); private final NumberFormat percentFormat = NumberFormat.getPercentInstance(); private final NumberFormat numberFormat = NumberFormat.getIntegerInstance(); private final long delayNanos = TimeUnit.NANOSECONDS.convert(100, TimeUnit.MILLISECONDS); // Don't skip the first update private volatile long lastRun = 0; @Override public void started() { super.started(); lastRun = -(delayNanos + 1); } @Override public void setDescription(String s) { try { console.println(); console.println(s); console.flush(); } catch (IOException e) { Throwables.propagate(e); } } @Override public synchronized void complete() { // avoid double logging if caller missbehaves if (super.isCompleted()) { return; } super.complete(); super.dispose(); try { log(getProgress()); console.println(); console.flush(); } catch (IOException e) { Throwables.propagate(e); } } @Override public synchronized void setProgress(float percent) { super.setProgress(percent); long nanoTime = platform.nanoTime(); if ((nanoTime - lastRun) > delayNanos) { lastRun = nanoTime; log(percent); } } private void log(float percent) { CursorBuffer cursorBuffer = console.getCursorBuffer(); cursorBuffer.clear(); String description = getDescription(); if (description != null) { cursorBuffer.write(description); } if (percent > 100) { cursorBuffer.write(numberFormat.format(percent)); } else { cursorBuffer.write(percentFormat.format(percent / 100f)); } try { console.redrawLine(); console.flush(); } catch (IOException e) { Throwables.propagate(e); } } }; } return this.progressListener; } static void addShutdownHook(final GeogigCLI cli) { // try to grafefully shutdown upon CTRL+C Runtime.getRuntime().addShutdownHook(new Thread() { @Override public void run() { if (cli.isRunning()) { System.err.println("Forced shut down, wait for geogig to be closed..."); System.err.flush(); cli.close(); System.err.println("geogig closed."); System.err.flush(); } } }); } @VisibleForTesting public void tryConfigureLogging() { Logging.tryConfigureLogging(getPlatform()); } }