/*
* Copyright (c) 2011-2013 TMate Software Ltd
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; version 2 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* For information on how to redistribute this software under
* the terms of a license other than GNU General Public License
* contact TMate Software at support@hg4j.com
*/
package org.tmatesoft.hg.repo;
import static org.tmatesoft.hg.internal.remote.Connector.*;
import static org.tmatesoft.hg.util.Outcome.Kind.Failure;
import static org.tmatesoft.hg.util.Outcome.Kind.Success;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.StreamTokenizer;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.tmatesoft.hg.auth.HgAuthFailedException;
import org.tmatesoft.hg.core.HgBadArgumentException;
import org.tmatesoft.hg.core.HgIOException;
import org.tmatesoft.hg.core.HgRemoteConnectionException;
import org.tmatesoft.hg.core.HgRepositoryNotFoundException;
import org.tmatesoft.hg.core.Nodeid;
import org.tmatesoft.hg.core.SessionContext;
import org.tmatesoft.hg.internal.BundleSerializer;
import org.tmatesoft.hg.internal.DataSerializer;
import org.tmatesoft.hg.internal.DataSerializer.OutputStreamSerializer;
import org.tmatesoft.hg.internal.EncodingHelper;
import org.tmatesoft.hg.internal.Experimental;
import org.tmatesoft.hg.internal.FileUtils;
import org.tmatesoft.hg.internal.Internals;
import org.tmatesoft.hg.internal.PropertyMarshal;
import org.tmatesoft.hg.internal.remote.Connector;
import org.tmatesoft.hg.internal.remote.RemoteConnectorDescriptor;
import org.tmatesoft.hg.util.Adaptable;
import org.tmatesoft.hg.util.LogFacility.Severity;
import org.tmatesoft.hg.util.Outcome;
import org.tmatesoft.hg.util.Pair;
/**
* WORK IN PROGRESS, DO NOT USE
*
* @see http://mercurial.selenic.com/wiki/WireProtocol
* @see http://mercurial.selenic.com/wiki/HttpCommandProtocol
*
* @author Artem Tikhomirov
* @author TMate Software Ltd.
*/
public class HgRemoteRepository implements SessionContext.Source {
private final boolean debug;
private HgLookup lookupHelper;
private final SessionContext sessionContext;
private Set<String> remoteCapabilities;
private Connector remote;
HgRemoteRepository(SessionContext ctx, RemoteDescriptor rd) throws HgBadArgumentException {
RemoteConnectorDescriptor rcd = Adaptable.Factory.getAdapter(rd, RemoteConnectorDescriptor.class, null);
if (rcd == null) {
throw new IllegalArgumentException(String.format("Present implementation supports remote connections via %s only", Connector.class.getName()));
}
sessionContext = ctx;
debug = new PropertyMarshal(ctx).getBoolean("hg4j.remote.debug", false);
remote = rcd.createConnector();
remote.init(rd /*sic! pass original*/, ctx, null);
}
public boolean isInvalid() throws HgRemoteConnectionException {
initCapabilities();
return remoteCapabilities.isEmpty();
}
/**
* @return human-readable address of the server, without user credentials or any other security information
*/
public String getLocation() {
return remote.getServerLocation();
}
public SessionContext getSessionContext() {
return sessionContext;
}
public List<Nodeid> heads() throws HgRemoteConnectionException {
if (isInvalid()) {
return Collections.emptyList();
}
try {
remote.sessionBegin();
InputStreamReader is = new InputStreamReader(remote.heads(), "US-ASCII");
StreamTokenizer st = new StreamTokenizer(is);
st.ordinaryChars('0', '9'); // wordChars performs |, hence need to 0 first
st.wordChars('0', '9');
st.eolIsSignificant(false);
LinkedList<Nodeid> parseResult = new LinkedList<Nodeid>();
while (st.nextToken() != StreamTokenizer.TT_EOF) {
parseResult.add(Nodeid.fromAscii(st.sval));
}
return parseResult;
} catch (IOException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand(CMD_HEADS).setServerInfo(getLocation());
} finally {
remote.sessionEnd();
}
}
public List<Nodeid> between(Nodeid tip, Nodeid base) throws HgRemoteConnectionException {
Range r = new Range(base, tip);
// XXX shall handle errors like no range key in the returned map, not sure how.
return between(Collections.singletonList(r)).get(r);
}
/**
* @param ranges
* @return map, where keys are input instances, values are corresponding server reply
* @throws HgRemoteConnectionException
*/
public Map<Range, List<Nodeid>> between(Collection<Range> ranges) throws HgRemoteConnectionException {
if (ranges.isEmpty() || isInvalid()) {
return Collections.emptyMap();
}
LinkedHashMap<Range, List<Nodeid>> rv = new LinkedHashMap<HgRemoteRepository.Range, List<Nodeid>>(ranges.size() * 4 / 3);
try {
remote.sessionBegin();
InputStreamReader is = new InputStreamReader(remote.between(ranges), "US-ASCII");
StreamTokenizer st = new StreamTokenizer(is);
st.ordinaryChars('0', '9');
st.wordChars('0', '9');
st.eolIsSignificant(true);
Iterator<Range> rangeItr = ranges.iterator();
LinkedList<Nodeid> currRangeList = null;
Range currRange = null;
boolean possiblyEmptyNextLine = true;
while (st.nextToken() != StreamTokenizer.TT_EOF) {
if (st.ttype == StreamTokenizer.TT_EOL) {
if (possiblyEmptyNextLine) {
// newline follows newline;
assert currRange == null;
assert currRangeList == null;
if (!rangeItr.hasNext()) {
throw new HgInvalidStateException("Internal error"); // TODO revisit-1.1
}
rv.put(rangeItr.next(), Collections.<Nodeid>emptyList());
} else {
if (currRange == null || currRangeList == null) {
throw new HgInvalidStateException("Internal error"); // TODO revisit-1.1
}
// indicate next range value is needed
currRange = null;
currRangeList = null;
possiblyEmptyNextLine = true;
}
} else {
possiblyEmptyNextLine = false;
if (currRange == null) {
if (!rangeItr.hasNext()) {
throw new HgInvalidStateException("Internal error"); // TODO revisit-1.1
}
currRange = rangeItr.next();
currRangeList = new LinkedList<Nodeid>();
rv.put(currRange, currRangeList);
}
Nodeid nid = Nodeid.fromAscii(st.sval);
currRangeList.addLast(nid);
}
}
is.close();
return rv;
} catch (IOException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand(CMD_BETWEEN).setServerInfo(getLocation());
} finally {
remote.sessionEnd();
}
}
public List<RemoteBranch> branches(List<Nodeid> nodes) throws HgRemoteConnectionException {
if (isInvalid()) {
return Collections.emptyList();
}
try {
remote.sessionBegin();
InputStreamReader is = new InputStreamReader(remote.branches(nodes), "US-ASCII");
StreamTokenizer st = new StreamTokenizer(is);
st.ordinaryChars('0', '9');
st.wordChars('0', '9');
st.eolIsSignificant(false);
ArrayList<Nodeid> parseResult = new ArrayList<Nodeid>(nodes.size() * 4);
while (st.nextToken() != StreamTokenizer.TT_EOF) {
parseResult.add(Nodeid.fromAscii(st.sval));
}
if (parseResult.size() != nodes.size() * 4) {
throw new HgRemoteConnectionException(String.format("Bad number of nodeids in result (shall be factor 4), expected %d, got %d", nodes.size()*4, parseResult.size()));
}
ArrayList<RemoteBranch> rv = new ArrayList<RemoteBranch>(nodes.size());
for (int i = 0; i < nodes.size(); i++) {
RemoteBranch rb = new RemoteBranch(parseResult.get(i*4), parseResult.get(i*4 + 1), parseResult.get(i*4 + 2), parseResult.get(i*4 + 3));
rv.add(rb);
}
return rv;
} catch (IOException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand(CMD_BRANCHES).setServerInfo(getLocation());
} finally {
remote.sessionEnd();
}
}
/*
* XXX need to describe behavior when roots arg is empty; our RepositoryComparator code currently returns empty lists when
* no common elements found, which in turn means we need to query changes starting with NULL nodeid.
*
* WireProtocol wiki: roots = a list of the latest nodes on every service side changeset branch that both the client and server know about.
*
* Perhaps, shall be named 'changegroup'
* Changegroup:
* http://mercurial.selenic.com/wiki/Merge
* http://mercurial.selenic.com/wiki/WireProtocol
*
* according to latter, bundleformat data is sent through zlib
* (there's no header like HG10?? with the server output, though,
* as one may expect according to http://mercurial.selenic.com/wiki/BundleFormat)
*/
public HgBundle getChanges(List<Nodeid> roots) throws HgRemoteConnectionException, HgRuntimeException {
if (isInvalid()) {
return null; // XXX valid retval???
}
List<Nodeid> _roots = roots.isEmpty() ? Collections.singletonList(Nodeid.NULL) : roots;
try {
remote.sessionBegin();
File tf = writeBundle(remote.changegroup(_roots));
if (debug) {
System.out.printf("Wrote bundle %s for roots %s\n", tf, roots);
}
return getLookupHelper().loadBundle(tf);
} catch (IOException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand(CMD_CHANGEGROUP).setServerInfo(getLocation());
} catch (HgRepositoryNotFoundException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand(CMD_CHANGEGROUP).setServerInfo(getLocation());
} finally {
remote.sessionEnd();
}
}
public void unbundle(HgBundle bundle, List<Nodeid> remoteHeads) throws HgRemoteConnectionException, HgRuntimeException {
if (remoteHeads == null) {
// TODO collect heads from bundle:
// bundle.inspectChangelog(new HeadCollector(for each c : if collected has c.p1 or c.p2, remove them. Add c))
// or get from remote server???
throw Internals.notImplemented();
}
if (isInvalid()) {
return;
}
DataSerializer.DataSource bundleData = BundleSerializer.newInstance(sessionContext, bundle);
OutputStream os = null;
try {
remote.sessionBegin();
os = remote.unbundle(bundleData.serializeLength(), remoteHeads);
bundleData.serialize(new OutputStreamSerializer(os));
os.flush();
os.close();
os = null;
} catch (IOException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand("unbundle").setServerInfo(getLocation());
} catch (HgIOException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand("unbundle").setServerInfo(getLocation());
} finally {
new FileUtils(sessionContext.getLog(), this).closeQuietly(os);
remote.sessionEnd();
}
}
public Bookmarks getBookmarks() throws HgRemoteConnectionException, HgRuntimeException {
initCapabilities();
if (!remoteCapabilities.contains(CMD_PUSHKEY)) { // (sic!) listkeys is available when pushkey in caps
return new Bookmarks(Collections.<Pair<String, Nodeid>>emptyList());
}
final String actionName = "Get remote bookmarks";
final List<Pair<String, String>> values = listkeys("bookmarks", actionName);
ArrayList<Pair<String, Nodeid>> rv = new ArrayList<Pair<String, Nodeid>>();
for (Pair<String, String> l : values) {
if (l.second().length() != Nodeid.SIZE_ASCII) {
sessionContext.getLog().dump(getClass(), Severity.Warn, "%s: bad nodeid '%s', ignored", actionName, l.second());
continue;
}
Nodeid n = Nodeid.fromAscii(l.second());
String bm = new String(l.first());
rv.add(new Pair<String, Nodeid>(bm, n));
}
return new Bookmarks(rv);
}
public Outcome updateBookmark(String name, Nodeid oldRev, Nodeid newRev) throws HgRemoteConnectionException, HgRuntimeException {
initCapabilities();
if (!remoteCapabilities.contains(CMD_PUSHKEY)) {
return new Outcome(Failure, "Server doesn't support pushkey protocol");
}
if (pushkey("Update remote bookmark", NS_BOOKMARKS, name, oldRev.toString(), newRev.toString())) {
return new Outcome(Success, String.format("Bookmark %s updated to %s", name, newRev.shortNotation()));
}
return new Outcome(Failure, String.format("Bookmark update (%s: %s -> %s) failed", name, oldRev.shortNotation(), newRev.shortNotation()));
}
public Phases getPhases() throws HgRemoteConnectionException, HgRuntimeException {
initCapabilities();
if (!remoteCapabilities.contains(CMD_PUSHKEY)) {
// old server defaults to publishing
return new Phases(true, Collections.<Nodeid>emptyList());
}
final List<Pair<String, String>> values = listkeys(NS_PHASES, "Get remote phases");
boolean publishing = false;
ArrayList<Nodeid> draftRoots = new ArrayList<Nodeid>();
for (Pair<String, String> l : values) {
if ("publishing".equalsIgnoreCase(l.first())) {
publishing = Boolean.parseBoolean(l.second());
continue;
}
Nodeid root = Nodeid.fromAscii(l.first());
int ph = Integer.parseInt(l.second());
if (ph == HgPhase.Draft.mercurialOrdinal()) {
draftRoots.add(root);
} else {
assert false;
sessionContext.getLog().dump(getClass(), Severity.Error, "Unexpected phase value %d for revision %s", ph, root);
}
}
return new Phases(publishing, draftRoots);
}
public Outcome updatePhase(HgPhase from, HgPhase to, Nodeid n) throws HgRemoteConnectionException, HgRuntimeException {
initCapabilities();
if (!remoteCapabilities.contains(CMD_PUSHKEY)) {
return new Outcome(Failure, "Server doesn't support pushkey protocol");
}
if (pushkey("Update remote phases", NS_PHASES, n.toString(), String.valueOf(from.mercurialOrdinal()), String.valueOf(to.mercurialOrdinal()))) {
return new Outcome(Success, String.format("Phase of %s updated to %s", n.shortNotation(), to.name()));
}
return new Outcome(Failure, String.format("Phase update (%s: %s -> %s) failed", n.shortNotation(), from.name(), to.name()));
}
@Override
public String toString() {
return getClass().getSimpleName() + '[' + getLocation() + ']';
}
private void initCapabilities() throws HgRemoteConnectionException {
if (remoteCapabilities != null) {
return;
}
try {
remote.connect();
} catch (HgAuthFailedException ex) {
throw new HgRemoteConnectionException("Failed to authenticate", ex).setServerInfo(remote.getServerLocation());
}
try {
remote.sessionBegin();
String capsLine = remote.getCapabilities();
String[] caps = capsLine.split("\\s");
remoteCapabilities = new HashSet<String>(Arrays.asList(caps));
} finally {
remote.sessionEnd();
}
}
private HgLookup getLookupHelper() {
if (lookupHelper == null) {
lookupHelper = new HgLookup(sessionContext);
}
return lookupHelper;
}
private List<Pair<String,String>> listkeys(String namespace, String actionName) throws HgRemoteConnectionException, HgRuntimeException {
try {
remote.sessionBegin();
ArrayList<Pair<String, String>> rv = new ArrayList<Pair<String, String>>();
InputStream response = remote.listkeys(namespace, actionName);
// output of listkeys is encoded with UTF-8
BufferedReader r = new BufferedReader(new InputStreamReader(response, EncodingHelper.getUTF8()));
String l;
while ((l = r.readLine()) != null) {
int sep = l.indexOf('\t');
if (sep == -1) {
sessionContext.getLog().dump(getClass(), Severity.Warn, "%s: bad line '%s', ignored", actionName, l);
continue;
}
rv.add(new Pair<String,String>(l.substring(0, sep), l.substring(sep+1)));
}
r.close();
return rv;
} catch (IOException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand(CMD_LISTKEYS).setServerInfo(getLocation());
} finally {
remote.sessionEnd();
}
}
private boolean pushkey(String opName, String namespace, String key, String oldValue, String newValue) throws HgRemoteConnectionException, HgRuntimeException {
try {
remote.sessionBegin();
final InputStream is = remote.pushkey(opName, namespace, key, oldValue, newValue);
int rv = is.read();
is.close();
return rv == '1';
} catch (IOException ex) {
throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand(CMD_PUSHKEY).setServerInfo(getLocation());
} finally {
remote.sessionEnd();
}
}
private File writeBundle(InputStream is) throws IOException {
File tf = File.createTempFile("hg4j-bundle-", null);
new FileUtils(sessionContext.getLog(), this).write(is, tf);
is.close();
return tf;
}
public static final class Range {
/**
* Root of the range, earlier revision
*/
public final Nodeid start;
/**
* Head of the range, later revision.
*/
public final Nodeid end;
/**
* @param from - root/base revision
* @param to - head/tip revision
*/
public Range(Nodeid from, Nodeid to) {
start = from;
end = to;
}
/**
* Append this range as pair of values 'end-start' to the supplied buffer and return the buffer.
*/
public StringBuilder append(StringBuilder sb) {
sb.append(end.toString());
sb.append('-');
sb.append(start.toString());
return sb;
}
}
public static final class RemoteBranch {
public final Nodeid head, root, p1, p2;
public RemoteBranch(Nodeid h, Nodeid r, Nodeid parent1, Nodeid parent2) {
head = h;
root = r;
p1 = parent1;
p2 = parent2;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (false == obj instanceof RemoteBranch) {
return false;
}
RemoteBranch o = (RemoteBranch) obj;
// in fact, p1 and p2 are not supposed to be null, ever (at least for RemoteBranch created from server output)
return head.equals(o.head) && root.equals(o.root) && (p1 == null && o.p1 == null || p1.equals(o.p1)) && (p2 == null && o.p2 == null || p2.equals(o.p2));
}
@Override
public int hashCode() {
return head.hashCode() ^ root.hashCode();
}
@Override
public String toString() {
String none = String.valueOf(-1);
String s1 = p1 == null || p1.isNull() ? none : p1.shortNotation();
String s2 = p2 == null || p2.isNull() ? none : p2.shortNotation();
return String.format("RemoteBranch[root: %s, head:%s, p1:%s, p2:%s]", root.shortNotation(), head.shortNotation(), s1, s2);
}
}
public static final class Bookmarks implements Iterable<Pair<String, Nodeid>> {
private final List<Pair<String, Nodeid>> bm;
private Bookmarks(List<Pair<String, Nodeid>> bookmarks) {
bm = bookmarks;
}
public Iterator<Pair<String, Nodeid>> iterator() {
return bm.iterator();
}
}
public static final class Phases {
private final boolean pub;
private final List<Nodeid> droots;
private Phases(boolean publishing, List<Nodeid> draftRoots) {
pub = publishing;
droots = draftRoots;
}
/**
* Non-publishing servers may (shall?) respond with a list of draft roots.
* This method doesn't make sense when {@link #isPublishingServer()} is <code>true</code>
*
* @return list of draft roots on remote server
*/
public List<Nodeid> draftRoots() {
return droots;
}
/**
* @return <code>true</code> if revisions on remote server shall be deemed published (either
* old server w/o explicit setting, or a new one with <code>phases.publish == true</code>)
*/
public boolean isPublishingServer() {
return pub;
}
}
/**
* Session context ({@link SessionContext#getRemoteDescriptor(URI)} gives descriptor of remote when asked.
* Clients may supply own descriptors e.g. if need to pass extra information into Authenticator.
* Present implementation of {@link HgRemoteRepository} will be happy with any {@link RemoteDescriptor} subclass
* as long as it's {@link Adaptable adaptable} to {@link RemoteConnectorDescriptor}
* @since 1.2
*/
@Experimental(reason="Provisional API. Work in progress")
public interface RemoteDescriptor {
/**
* @return remote location, never <code>null</code>
*/
URI getURI();
}
}