view src/org/tmatesoft/hg/repo/HgRemoteRepository.java @ 709:497e697636fc

Report merged lines as changed block if possible, not as a sequence of added/deleted blocks. To facilitate access to merge parent lines AddBlock got mergeLineAt() method that reports index of the line in the second parent (if any), while insertedAt() has been changed to report index in the first parent always
author Artem Tikhomirov <tikhomirov.artem@gmail.com>
date Wed, 21 Aug 2013 16:23:27 +0200
parents a483b2b68a2e
children
line wrap: on
line source
/*
 * 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();
	}
}