/* 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.porcelain; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Map; import jline.console.ConsoleReader; import org.fusesource.jansi.Ansi; import org.fusesource.jansi.Ansi.Color; import org.geotools.util.Range; import org.locationtech.geogig.api.GeoGIG; import org.locationtech.geogig.api.ObjectId; import org.locationtech.geogig.api.Platform; import org.locationtech.geogig.api.Ref; import org.locationtech.geogig.api.RevCommit; import org.locationtech.geogig.api.RevPerson; import org.locationtech.geogig.api.SymRef; import org.locationtech.geogig.api.plumbing.ForEachRef; import org.locationtech.geogig.api.plumbing.ParseTimestamp; import org.locationtech.geogig.api.plumbing.RefParse; import org.locationtech.geogig.api.plumbing.RevParse; import org.locationtech.geogig.api.plumbing.diff.DiffEntry; import org.locationtech.geogig.api.porcelain.DiffOp; import org.locationtech.geogig.api.porcelain.LogOp; import org.locationtech.geogig.cli.AbstractCommand; import org.locationtech.geogig.cli.CLICommand; import org.locationtech.geogig.cli.GeogigCLI; import org.locationtech.geogig.cli.InvalidParameterException; import org.locationtech.geogig.cli.annotation.ReadOnly; import com.beust.jcommander.Parameters; import com.beust.jcommander.ParametersDelegate; import com.beust.jcommander.internal.Lists; import com.beust.jcommander.internal.Maps; import com.google.common.base.Optional; import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; /** * Shows the commit logs. * <p> * CLI proxy for {@link org.locationtech.geogig.api.porcelain.LogOp} * <p> * Usage: * <ul> * <li> {@code geogig log [<options>]} * </ul> * * @see org.locationtech.geogig.api.porcelain.LogOp */ @ReadOnly @Parameters(commandNames = "log", commandDescription = "Show commit logs") public class Log extends AbstractCommand implements CLICommand { public enum LOG_DETAIL { SUMMARY, NAMES_ONLY, STATS, NOTHING }; @ParametersDelegate public final LogArgs args = new LogArgs(); private Map<ObjectId, String> refs; private GeoGIG geogig; private ConsoleReader console; /** * Executes the log command using the provided options. * * @param cli * @throws IOException * @see org.locationtech.geogig.cli.AbstractCommand#runInternal(org.locationtech.geogig.cli.GeogigCLI) */ @Override public void runInternal(GeogigCLI cli) throws IOException { checkParameter(!(args.summary && args.oneline), "--summary and --oneline cannot be used together"); checkParameter(!(args.stats && args.oneline), "--stats and --oneline cannot be used together"); checkParameter(!(args.stats && args.oneline), "--name-only and --oneline cannot be used together"); geogig = cli.getGeogig(); LogOp op = geogig.command(LogOp.class).setTopoOrder(args.topo) .setFirstParentOnly(args.firstParent); refs = Maps.newHashMap(); if (args.decoration) { Optional<Ref> head = geogig.command(RefParse.class).setName(Ref.HEAD).call(); refs.put(head.get().getObjectId(), Ref.HEAD); ImmutableSet<Ref> set = geogig.command(ForEachRef.class) .setPrefixFilter(Ref.REFS_PREFIX).call(); for (Ref ref : set) { ObjectId id = ref.getObjectId(); if (refs.containsKey(id)) { refs.put(id, refs.get(id) + ", " + ref.getName()); } else { refs.put(id, ref.getName()); } } } if (args.all) { ImmutableSet<Ref> refs = geogig.command(ForEachRef.class) .setPrefixFilter(Ref.REFS_PREFIX).call(); List<ObjectId> list = Lists.newArrayList(); for (Ref ref : refs) { list.add(ref.getObjectId()); } Optional<Ref> head = geogig.command(RefParse.class).setName(Ref.HEAD).call(); if (head.isPresent()) { Ref ref = head.get(); if (ref instanceof SymRef) { ObjectId id = ref.getObjectId(); list.remove(id); list.add(id);// put the HEAD ref in the last position, to give it preference } } for (ObjectId id : list) { op.addCommit(id); } } else if (args.branch != null) { Optional<Ref> obj = geogig.command(RefParse.class).setName(args.branch).call(); checkParameter(obj.isPresent(), "Wrong branch name: " + args.branch); op.addCommit(obj.get().getObjectId()); } if (args.author != null && !args.author.isEmpty()) { op.setAuthor(args.author); } if (args.committer != null && !args.committer.isEmpty()) { op.setCommiter(args.committer); } if (args.skip != null) { op.setSkip(args.skip.intValue()); } if (args.limit != null) { op.setLimit(args.limit.intValue()); } if (args.since != null || args.until != null) { Date since = new Date(0); Date until = new Date(); if (args.since != null) { since = new Date(geogig.command(ParseTimestamp.class).setString(args.since).call()); } if (args.until != null) { until = new Date(geogig.command(ParseTimestamp.class).setString(args.until).call()); if (args.all) { throw new InvalidParameterException( "Cannot specify 'until' commit when listing all branches"); } } op.setTimeRange(new Range<Date>(Date.class, since, until)); } if (!args.sinceUntilPaths.isEmpty()) { List<String> sinceUntil = ImmutableList.copyOf((Splitter.on("..") .split(args.sinceUntilPaths.get(0)))); checkParameter(sinceUntil.size() == 1 || sinceUntil.size() == 2, "Invalid refSpec format, expected [<until>]|[<since>..<until>]: %s", args.sinceUntilPaths.get(0)); String sinceRefSpec; String untilRefSpec; if (sinceUntil.size() == 1) { // just until was given sinceRefSpec = null; untilRefSpec = sinceUntil.get(0); } else { sinceRefSpec = sinceUntil.get(0); untilRefSpec = sinceUntil.get(1); } if (sinceRefSpec != null) { Optional<ObjectId> since; since = geogig.command(RevParse.class).setRefSpec(sinceRefSpec).call(); checkParameter(since.isPresent(), "Object not found '%s'", sinceRefSpec); op.setSince(since.get()); } if (untilRefSpec != null) { if (args.all) { throw new InvalidParameterException( "Cannot specify 'until' commit when listing all branches"); } Optional<ObjectId> until; until = geogig.command(RevParse.class).setRefSpec(untilRefSpec).call(); checkParameter(until.isPresent(), "Object not found '%s'", sinceRefSpec); op.setUntil(until.get()); } } if (!args.pathNames.isEmpty()) { for (String s : args.pathNames) { op.addPath(s); } } Iterator<RevCommit> log = op.call(); this.console = cli.getConsole(); if (!log.hasNext()) { console.println("No commits to show"); console.flush(); return; } LogEntryPrinter printer; if (args.oneline) { printer = new OneLineConverter(); } else { LOG_DETAIL detail; if (args.summary) { detail = LOG_DETAIL.SUMMARY; } else if (args.names) { detail = LOG_DETAIL.NAMES_ONLY; } else if (args.stats) { detail = LOG_DETAIL.STATS; } else { detail = LOG_DETAIL.NOTHING; } printer = new StandardConverter(detail, geogig.getPlatform()); } while (log.hasNext()) { printer.print(log.next()); console.flush(); } } interface LogEntryPrinter { /** * @param geogig * @param console * @param entry * @throws IOException */ void print(RevCommit commit) throws IOException; } private class OneLineConverter implements LogEntryPrinter { @Override public void print(RevCommit commit) throws IOException { Ansi ansi = newAnsi(console.getTerminal()); ansi.fg(Color.YELLOW).a(getIdAsString(commit.getId())).reset(); String message = Strings.nullToEmpty(commit.getMessage()); String title = Splitter.on('\n').split(message).iterator().next(); ansi.a(" ").a(title); console.println(ansi.toString()); } } private class StandardConverter implements LogEntryPrinter { private SimpleDateFormat DATE_FORMAT; private long now; private LOG_DETAIL detail; public StandardConverter(final LOG_DETAIL detail, final Platform platform) { now = platform.currentTimeMillis(); DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z"); this.detail = detail; } @Override public void print(RevCommit commit) throws IOException { Ansi ansi = newAnsi(console.getTerminal()); ansi.a("Commit: ").fg(Color.YELLOW).a(getIdAsString(commit.getId())).reset().newline(); if (commit.getParentIds().size() > 1) { ansi.a("Merge: "); for (ObjectId parent : commit.getParentIds()) { ansi.a(parent.toString().substring(0, 7)).a(" "); } ansi.newline(); } ansi.a("Author: ").fg(Color.GREEN).a(formatPerson(commit.getAuthor())).reset() .newline(); final long timestamp = commit.getAuthor().getTimestamp(); int timeZoneOffset = commit.getAuthor().getTimeZoneOffset(); if (args.utcDateFormat) { timeZoneOffset = 0; } String friendlyString = estimateSince(now, timestamp); DATE_FORMAT.getCalendar().getTimeZone().setRawOffset(timeZoneOffset); String formattedDate = DATE_FORMAT.format(timestamp); ansi.a("Date: (").fg(Color.RED).a(friendlyString).reset().a(") ").a(formattedDate) .newline(); ansi.a("Subject: ").a(commit.getMessage()).newline(); if ((detail.equals(LOG_DETAIL.NAMES_ONLY)) && commit.getParentIds().size() == 1) { ansi.a("Affected paths:").newline(); Iterator<DiffEntry> diff = geogig.command(DiffOp.class) .setOldVersion(commit.parentN(0).get()).setNewVersion(commit.getId()) .call(); DiffEntry diffEntry; while (diff.hasNext()) { diffEntry = diff.next(); ansi.a("\t" + diffEntry.newPath()).newline(); } } if (detail.equals(LOG_DETAIL.STATS) && commit.getParentIds().size() == 1) { Iterator<DiffEntry> diff = geogig.command(DiffOp.class) .setOldVersion(commit.parentN(0).get()).setNewVersion(commit.getId()) .call(); int adds = 0, deletes = 0, changes = 0; DiffEntry diffEntry; while (diff.hasNext()) { diffEntry = diff.next(); switch (diffEntry.changeType()) { case ADDED: ++adds; break; case REMOVED: ++deletes; break; case MODIFIED: ++changes; break; } } ansi.a("Changes:"); ansi.fg(Color.GREEN).a(adds).reset().a(" features added, ").fg(Color.YELLOW) .a(changes).reset().a(" changed, ").fg(Color.RED).a(deletes).reset() .a(" deleted.").reset().newline(); } console.println(ansi.toString()); if (detail.equals(LOG_DETAIL.SUMMARY) && commit.getParentIds().size() == 1) { ansi.a("Changes:").newline(); Iterator<DiffEntry> diff = geogig.command(DiffOp.class) .setOldVersion(commit.parentN(0).get()).setNewVersion(commit.getId()) .call(); DiffEntry diffEntry; while (diff.hasNext()) { diffEntry = diff.next(); if (detail.equals(LOG_DETAIL.SUMMARY)) { new FullDiffPrinter(true, false).print(geogig, console, diffEntry); } } } } } /** * Converts a RevPerson for into a readable string. * * @param person the person to format. * @return the formatted string * @see RevPerson */ private String formatPerson(RevPerson person) { StringBuilder sb = new StringBuilder(); sb.append(person.getName().or("<name not set>")); if (person.getEmail().isPresent()) { sb.append(" <").append(person.getEmail().get()).append('>'); } return sb.toString(); } /** * Converts a timestamp into a readable string that represents the rough time since that * timestamp. * * @param now * @param timestamp * @return */ private String estimateSince(final long now, long timestamp) { long diff = now - timestamp; final long seconds = 1000; final long minutes = seconds * 60; final long hours = minutes * 60; final long days = hours * 24; final long weeks = days * 7; final long months = days * 30; final long years = days * 365; if (diff > years) { return diff / years + " years ago"; } if (diff > months) { return diff / months + " months ago"; } if (diff > weeks) { return diff / weeks + " weeks ago"; } if (diff > days) { return diff / days + " days ago"; } if (diff > hours) { return diff / hours + " hours ago"; } if (diff > minutes) { return diff / minutes + " minutes ago"; } if (diff > seconds) { return diff / seconds + " seconds ago"; } return "just now"; } /** * Returns an Id as a string, decorating or abbreviating it if needed * * @param id * @return */ private String getIdAsString(ObjectId id) { StringBuilder sb = new StringBuilder(); if (args.abbrev) { sb.append(id.toString().substring(0, 7)); } else { sb.append(id.toString()); } if (refs.containsKey(id)) { sb.append(" ("); sb.append(refs.get(id)); sb.append(')'); } return sb.toString(); } }