changeset 649:e79cf9a8130b

Push: phase4 - update local and remote phase information
author Artem Tikhomirov <tikhomirov.artem@gmail.com>
date Wed, 26 Jun 2013 20:52:38 +0200
parents 690e71d29bf6
children 3b275cc2d2aa
files src/org/tmatesoft/hg/core/HgPushCommand.java src/org/tmatesoft/hg/internal/LineReader.java src/org/tmatesoft/hg/internal/PhasesHelper.java src/org/tmatesoft/hg/internal/RevisionSet.java src/org/tmatesoft/hg/repo/HgPhase.java src/org/tmatesoft/hg/repo/HgRemoteRepository.java
diffstat 6 files changed, 302 insertions(+), 39 deletions(-) [+]
line wrap: on
line diff
--- a/src/org/tmatesoft/hg/core/HgPushCommand.java	Tue Jun 25 20:48:37 2013 +0200
+++ b/src/org/tmatesoft/hg/core/HgPushCommand.java	Wed Jun 26 20:52:38 2013 +0200
@@ -19,10 +19,14 @@
 import java.io.File;
 import java.io.IOException;
 import java.net.URL;
+import java.util.ArrayList;
 import java.util.List;
 
 import org.tmatesoft.hg.internal.BundleGenerator;
+import org.tmatesoft.hg.internal.Internals;
+import org.tmatesoft.hg.internal.PhasesHelper;
 import org.tmatesoft.hg.internal.RepositoryComparator;
+import org.tmatesoft.hg.internal.RevisionSet;
 import org.tmatesoft.hg.repo.HgBookmarks;
 import org.tmatesoft.hg.repo.HgBundle;
 import org.tmatesoft.hg.repo.HgChangelog;
@@ -30,12 +34,14 @@
 import org.tmatesoft.hg.repo.HgInvalidStateException;
 import org.tmatesoft.hg.repo.HgLookup;
 import org.tmatesoft.hg.repo.HgParentChildMap;
+import org.tmatesoft.hg.repo.HgPhase;
 import org.tmatesoft.hg.repo.HgRemoteRepository;
 import org.tmatesoft.hg.repo.HgRepository;
 import org.tmatesoft.hg.repo.HgRuntimeException;
 import org.tmatesoft.hg.util.CancelledException;
 import org.tmatesoft.hg.util.Pair;
 import org.tmatesoft.hg.util.ProgressSupport;
+import org.tmatesoft.hg.util.LogFacility.Severity;
 
 /**
  * 
@@ -63,14 +69,16 @@
 			//
 			// find out missing
 			// TODO refactor same code in HgOutgoingCommand #getComparator and #getParentHelper
-			final HgParentChildMap<HgChangelog> parentHelper = new HgParentChildMap<HgChangelog>(repo.getChangelog());
+			final HgChangelog clog = repo.getChangelog();
+			final HgParentChildMap<HgChangelog> parentHelper = new HgParentChildMap<HgChangelog>(clog);
 			parentHelper.init();
 			final RepositoryComparator comparator = new RepositoryComparator(parentHelper, remoteRepo);
 			comparator.compare(new ProgressSupport.Sub(progress, 50), getCancelSupport(null, true));
 			List<Nodeid> l = comparator.getLocalOnlyRevisions();
 			//
 			// prepare bundle
-			BundleGenerator bg = new BundleGenerator(HgInternals.getImplementationRepo(repo));
+			final Internals implRepo = HgInternals.getImplementationRepo(repo);
+			BundleGenerator bg = new BundleGenerator(implRepo);
 			File bundleFile = bg.create(l);
 			progress.worked(20);
 			HgBundle b = new HgLookup(repo.getSessionContext()).loadBundle(bundleFile);
@@ -79,14 +87,65 @@
 			remoteRepo.unbundle(b, comparator.getRemoteHeads());
 			progress.worked(20);
 			//
-			// FIXME update phase information
-//			remote.listkeys("phases");
+			// update phase information
+			PhasesHelper phaseHelper = new PhasesHelper(implRepo, parentHelper);
+			if (phaseHelper.isCapableOfPhases()) {
+				RevisionSet outgoing = new RevisionSet(l);
+				RevisionSet presentSecret = phaseHelper.allSecret();
+				RevisionSet presentDraft = phaseHelper.allDraft();
+				RevisionSet secretLeft, draftLeft;
+				HgRemoteRepository.Phases remotePhases = remoteRepo.getPhases();
+				if (remotePhases.isPublishingServer()) {
+					// although it's unlikely outgoing would affect secret changesets,
+					// it doesn't hurt to check secret roots along with draft ones
+					secretLeft = presentSecret.subtract(outgoing);
+					draftLeft = presentDraft.subtract(outgoing);
+				} else {
+					// shall merge local and remote phase states
+					ArrayList<Nodeid> knownRemoteDraftRoots = new ArrayList<Nodeid>();
+					for (Nodeid rdr : remotePhases.draftRoots()) {
+						if (clog.isKnown(rdr)) {
+							knownRemoteDraftRoots.add(rdr);
+						}
+					}
+					// childrenOf(knownRemoteDraftRoots) is everything remote may treat as Draft
+					RevisionSet remoteDrafts = new RevisionSet(parentHelper.childrenOf(knownRemoteDraftRoots));
+					List<Nodeid> localChildrenNotSent = parentHelper.childrenOf(outgoing.heads(parentHelper).asList());
+					// remote shall know only what we've sent, subtract revisions we didn't actually sent
+					remoteDrafts = remoteDrafts.subtract(new RevisionSet(localChildrenNotSent));
+					// if there's a remote draft root that points to revision we know is public
+					RevisionSet remoteDraftsLocallyPublic = remoteDrafts.subtract(presentSecret).subtract(presentDraft);
+					if (!remoteDraftsLocallyPublic.isEmpty()) {
+						// foreach remoteDraftsLocallyPublic.heads() do push Draft->Public
+						for (Nodeid n : remoteDraftsLocallyPublic.heads(parentHelper)) {
+							try {
+								remoteRepo.updatePhase(HgPhase.Draft, HgPhase.Public, n);
+							} catch (HgRemoteConnectionException ex) {
+								implRepo.getLog().dump(getClass(), Severity.Error, ex, String.format("Failed to update phase of %s", n.shortNotation()));
+							}
+						}
+						remoteDrafts = remoteDrafts.subtract(remoteDraftsLocallyPublic);
+					}
+					// revisions that cease to be secret (gonna become Public), e.g. someone else pushed them
+					RevisionSet secretGone = presentSecret.intersect(remoteDrafts);
+					// trace parents of these published secret revisions
+					RevisionSet secretMadePublic = presentSecret.parentsOf(secretGone, parentHelper);
+					secretLeft = presentSecret.subtract(secretGone).subtract(secretMadePublic);
+					// same for drafts
+					RevisionSet draftGone = presentDraft.intersect(remoteDrafts);
+					RevisionSet draftMadePublic = presentDraft.parentsOf(draftGone, parentHelper);
+					draftLeft = presentDraft.subtract(draftGone).subtract(draftMadePublic);
+				}
+				final RevisionSet newDraftRoots = draftLeft.roots(parentHelper);
+				final RevisionSet newSecretRoots = secretLeft.roots(parentHelper);
+				phaseHelper.updateRoots(newDraftRoots.asList(), newSecretRoots.asList());
+			}
 			progress.worked(5);
 			//
 			// update bookmark information
 			HgBookmarks localBookmarks = repo.getBookmarks();
 			if (!localBookmarks.getAllBookmarks().isEmpty()) {
-				for (Pair<String,Nodeid> bm : remoteRepo.bookmarks()) {
+				for (Pair<String,Nodeid> bm : remoteRepo.getBookmarks()) {
 					Nodeid localRevision = localBookmarks.getRevision(bm.first());
 					if (localRevision == null || !parentHelper.knownNode(bm.second())) {
 						continue;
@@ -98,7 +157,6 @@
 					}
 				}
 			}
-//			remote.listkeys("bookmarks");
 			// XXX WTF is obsolete in namespaces key??
 			progress.worked(5);
 		} catch (IOException ex) {
@@ -120,7 +178,7 @@
 	 */
 	public static void main(String[] args) throws Exception {
 		final HgLookup hgLookup = new HgLookup();
-		HgRepository r = hgLookup.detect("/home/artem/hg/junit-test-repos/log-1/");
+		HgRepository r = hgLookup.detect("/home/artem/hg/test-phases/");
 		HgRemoteRepository rr = hgLookup.detect(new URL("http://localhost:8000/"));
 		new HgPushCommand(r).destination(rr).execute();
 	}
--- a/src/org/tmatesoft/hg/internal/LineReader.java	Tue Jun 25 20:48:37 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/LineReader.java	Wed Jun 26 20:52:38 2013 +0200
@@ -95,7 +95,14 @@
 			return this;
 		}
 
-		public <T> void read(LineConsumer<T> consumer, T paramObj) throws HgIOException {
+		/**
+		 * 
+		 * @param consumer where to pipe read lines to
+		 * @param paramObj parameterizes consumer
+		 * @return paramObj value for convenience
+		 * @throws HgIOException if there's {@link IOException} while reading file
+		 */
+		public <T> T read(LineConsumer<T> consumer, T paramObj) throws HgIOException {
 			BufferedReader statusFileReader = null;
 			try {
 //				consumer.begin(file, paramObj);
@@ -119,6 +126,7 @@
 						ok = consumer.consume(line, paramObj);
 					}
 				}
+				return paramObj;
 			} catch (IOException ex) {
 				throw new HgIOException(ex.getMessage(), ex, file);
 			} finally {
--- a/src/org/tmatesoft/hg/internal/PhasesHelper.java	Tue Jun 25 20:48:37 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/PhasesHelper.java	Wed Jun 26 20:52:38 2013 +0200
@@ -19,19 +19,20 @@
 import static org.tmatesoft.hg.repo.HgPhase.Draft;
 import static org.tmatesoft.hg.repo.HgPhase.Secret;
 import static org.tmatesoft.hg.repo.HgRepositoryFiles.Phaseroots;
-import static org.tmatesoft.hg.util.LogFacility.Severity.Info;
 import static org.tmatesoft.hg.util.LogFacility.Severity.Warn;
 
-import java.io.BufferedReader;
 import java.io.File;
-import java.io.FileReader;
+import java.io.FileWriter;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.List;
 
 import org.tmatesoft.hg.core.HgChangeset;
+import org.tmatesoft.hg.core.HgIOException;
 import org.tmatesoft.hg.core.Nodeid;
 import org.tmatesoft.hg.repo.HgChangelog;
 import org.tmatesoft.hg.repo.HgInvalidControlFileException;
@@ -141,9 +142,34 @@
 		return allOf(HgPhase.Secret);
 	}
 	
+	/**
+	 * @return all revisions with draft phase
+	 */
 	public RevisionSet allDraft() {
 		return allOf(HgPhase.Draft).subtract(allOf(HgPhase.Secret));
 	}
+	
+	public void updateRoots(Collection<Nodeid> draftRoots, Collection<Nodeid> secretRoots) throws HgInvalidControlFileException {
+		draftPhaseRoots = draftRoots.isEmpty() ? Collections.<Nodeid>emptyList() : new ArrayList<Nodeid>(draftRoots);
+		secretPhaseRoots = secretRoots.isEmpty() ? Collections.<Nodeid>emptyList() : new ArrayList<Nodeid>(secretRoots);
+		String fmt = "%d %s\n";
+		File phaseroots = repo.getRepositoryFile(Phaseroots);
+		FileWriter fw = null;
+		try {
+			fw = new FileWriter(phaseroots);
+			for (Nodeid n : secretPhaseRoots) {
+				fw.write(String.format(fmt, HgPhase.Secret.mercurialOrdinal(), n.toString()));
+			}
+			for (Nodeid n : draftPhaseRoots) {
+				fw.write(String.format(fmt, HgPhase.Draft.mercurialOrdinal(), n.toString()));
+			}
+			fw.flush();
+		} catch (IOException ex) {
+			throw new HgInvalidControlFileException(ex.getMessage(), ex, phaseroots);
+		} finally {
+			new FileUtils(repo.getLog()).closeQuietly(fw);
+		}
+	}
 
 	/**
 	 * For a given phase, collect all revisions with phase that is the same or more private (i.e. for Draft, returns Draft+Secret)
@@ -168,16 +194,15 @@
 
 	private Boolean readRoots() throws HgRuntimeException {
 		File phaseroots = repo.getRepositoryFile(Phaseroots);
-		BufferedReader br = null;
 		try {
 			if (!phaseroots.exists()) {
 				return Boolean.FALSE;
 			}
+			LineReader lr = new LineReader(phaseroots, repo.getLog());
+			final Collection<String> lines = lr.read(new LineReader.SimpleLineCollector(), new LinkedList<String>());
 			HashMap<HgPhase, List<Nodeid>> phase2roots = new HashMap<HgPhase, List<Nodeid>>();
-			br = new BufferedReader(new FileReader(phaseroots));
-			String line;
-			while ((line = br.readLine()) != null) {
-				String[] lc = line.trim().split("\\s+");
+			for (String line : lines) {
+				String[] lc = line.split("\\s+");
 				if (lc.length == 0) {
 					continue;
 				}
@@ -200,17 +225,8 @@
 			}
 			draftPhaseRoots = phase2roots.containsKey(Draft) ? phase2roots.get(Draft) : Collections.<Nodeid>emptyList();
 			secretPhaseRoots = phase2roots.containsKey(Secret) ? phase2roots.get(Secret) : Collections.<Nodeid>emptyList();
-		} catch (IOException ex) {
-			throw new HgInvalidControlFileException(ex.toString(), ex, phaseroots);
-		} finally {
-			if (br != null) {
-				try {
-					br.close();
-				} catch (IOException ex) {
-					repo.getSessionContext().getLog().dump(getClass(), Info, ex, null);
-					// ignore the exception otherwise 
-				}
-			}
+		} catch (HgIOException ex) {
+			throw new HgInvalidControlFileException(ex, true);
 		}
 		return Boolean.TRUE;
 	}
--- a/src/org/tmatesoft/hg/internal/RevisionSet.java	Tue Jun 25 20:48:37 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/RevisionSet.java	Wed Jun 26 20:52:38 2013 +0200
@@ -16,12 +16,17 @@
  */
 package org.tmatesoft.hg.internal;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
 import java.util.Set;
 
 import org.tmatesoft.hg.core.Nodeid;
+import org.tmatesoft.hg.repo.HgChangelog;
+import org.tmatesoft.hg.repo.HgParentChildMap;
 
 /**
  * Unmodifiable collection of revisions with handy set operations
@@ -29,7 +34,7 @@
  * @author Artem Tikhomirov
  * @author TMate Software Ltd.
  */
-public final class RevisionSet {
+public final class RevisionSet implements Iterable<Nodeid> {
 	
 	private final Set<Nodeid> elements;
 	
@@ -45,12 +50,70 @@
 		}
 	}
 
-	public RevisionSet roots() {
-		throw Internals.notImplemented();
+	/**
+	 * elements of the set with no parents or parents not from the same set 
+	 */
+	public RevisionSet roots(HgParentChildMap<HgChangelog> ph) {
+		HashSet<Nodeid> copy = new HashSet<Nodeid>(elements);
+		for (Nodeid n : elements) {
+			assert ph.knownNode(n);
+			Nodeid p1 = ph.firstParent(n);
+			if (p1 != null && elements.contains(p1)) {
+				copy.remove(n);
+				continue;
+			}
+			Nodeid p2 = ph.secondParent(n);
+			if (p2 != null && elements.contains(p2)) {
+				copy.remove(n);
+				continue;
+			}
+		}
+		return copy.size() == elements.size() ? this : new RevisionSet(copy);
 	}
 	
-	public RevisionSet heads() {
-		throw Internals.notImplemented();
+	/**
+	 * elements of the set that has no children in this set 
+	 */
+	public RevisionSet heads(HgParentChildMap<HgChangelog> ph) {
+		HashSet<Nodeid> copy = new HashSet<Nodeid>(elements);
+		// can't do copy.removeAll(ph.childrenOf(asList())); as actual heads are indeed children of some other node
+		for (Nodeid n : elements) {
+			assert ph.knownNode(n);
+			Nodeid p1 = ph.firstParent(n);
+			Nodeid p2 = ph.secondParent(n);
+			if (p1 != null && elements.contains(p1)) {
+				copy.remove(p1);
+			}
+			if (p2 != null && elements.contains(p2)) {
+				copy.remove(p2);
+			}
+		}
+		return copy.size() == elements.size() ? this : new RevisionSet(copy);
+	}
+
+	/**
+	 * Immediate parents of the supplied children set found in this one.
+	 */
+	public RevisionSet parentsOf(RevisionSet children, HgParentChildMap<HgChangelog> parentHelper) {
+		if (isEmpty()) {
+			return this;
+		}
+		if (children.isEmpty()) {
+			return children;
+		}
+		RevisionSet chRoots = children.roots(parentHelper);
+		HashSet<Nodeid> parents = new HashSet<Nodeid>();
+		for (Nodeid n : chRoots.elements) {
+			Nodeid p1 = parentHelper.firstParent(n);
+			Nodeid p2 = parentHelper.secondParent(n);
+			if (p1 != null && elements.contains(p1)) {
+				parents.add(p1);
+			}
+			if (p2 != null && elements.contains(p2)) {
+				parents.add(p2);
+			}
+		}
+		return new RevisionSet(parents);
 	}
 
 	public RevisionSet intersect(RevisionSet other) {
@@ -109,6 +172,15 @@
 		return elements.isEmpty();
 	}
 
+
+	public List<Nodeid> asList() {
+		return new ArrayList<Nodeid>(elements);
+	}
+	
+	public Iterator<Nodeid> iterator() {
+		return elements.iterator();
+	}
+	
 	@Override
 	public String toString() {
 		StringBuilder sb = new StringBuilder();
--- a/src/org/tmatesoft/hg/repo/HgPhase.java	Tue Jun 25 20:48:37 2013 +0200
+++ b/src/org/tmatesoft/hg/repo/HgPhase.java	Wed Jun 26 20:52:38 2013 +0200
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2012 TMate Software Ltd
+ * Copyright (c) 2012-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
@@ -58,4 +58,14 @@
 		}
 		throw new IllegalArgumentException(String.format("Bad phase name: %d", value));
 	}
+	
+	/**
+	 * @return integer value Mercurial uses to identify the phase
+	 */
+	public int mercurialOrdinal() {
+		if (this == Undefined) {
+			throw new IllegalStateException("Undefined phase is an artifical value, which doesn't possess a valid native mercurial ordinal");
+		}
+		return ordinal(); // what a coincidence
+	}
 }
--- a/src/org/tmatesoft/hg/repo/HgRemoteRepository.java	Tue Jun 25 20:48:37 2013 +0200
+++ b/src/org/tmatesoft/hg/repo/HgRemoteRepository.java	Wed Jun 26 20:52:38 2013 +0200
@@ -66,8 +66,10 @@
 import org.tmatesoft.hg.internal.Internals;
 import org.tmatesoft.hg.internal.DataSerializer.OutputStreamSerializer;
 import org.tmatesoft.hg.internal.PropertyMarshal;
+import org.tmatesoft.hg.util.Outcome;
 import org.tmatesoft.hg.util.Pair;
 import org.tmatesoft.hg.util.LogFacility.Severity;
+import org.tmatesoft.hg.util.Outcome.Kind;
 
 /**
  * WORK IN PROGRESS, DO NOT USE
@@ -450,7 +452,7 @@
 		}
 	}
 
-	public List<Pair<String,Nodeid>> bookmarks() throws HgRemoteConnectionException, HgRuntimeException {
+	public Bookmarks getBookmarks() throws HgRemoteConnectionException, HgRuntimeException {
 		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>>();
@@ -463,7 +465,7 @@
 			String bm = new String(l.first());
 			rv.add(new Pair<String, Nodeid>(bm, n));
 		}
-		return rv;
+		return new Bookmarks(rv);
 	}
 
 	public void updateBookmark(String name, Nodeid oldRev, Nodeid newRev) throws HgRemoteConnectionException, HgRuntimeException {
@@ -488,21 +490,48 @@
 		}
 	}
 	
-	private void phases() throws HgRemoteConnectionException, HgRuntimeException {
+	public Phases getPhases() throws HgRemoteConnectionException, HgRuntimeException {
+		initCapabilities();
+		if (!remoteCapabilities.contains("pushkey")) {
+			// old server defaults to publishing
+			return new Phases(true, Collections.<Nodeid>emptyList());
+		}
 		final List<Pair<String, String>> values = listkeys("phases", "Get remote phases");
+		boolean publishing = true;
+		ArrayList<Nodeid> draftRoots = new ArrayList<Nodeid>();
 		for (Pair<String, String> l : values) {
-			System.out.printf("%s : %s\n", l.first(), l.second());
+			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 {
+		if (pushkey("phases", n.toString(), String.valueOf(from.mercurialOrdinal()), String.valueOf(to.mercurialOrdinal()))) {
+			return new Outcome(Kind.Success, String.format("Phase of %s updated to %s", n.shortNotation(), to.name()));
+		}
+		return new Outcome(Kind.Failure, String.format("Phase update (%s: %s -> %s) failed", n.shortNotation(), from.name(), to.name()));
+	}
+
+	
 	public static void main(String[] args) throws Exception {
 		final HgRemoteRepository r = new HgLookup().detectRemote("http://selenic.com/hg", null);
 		if (r.isInvalid()) {
 			return;
 		}
 		System.out.println(r.remoteCapabilities);
-		r.phases();
-		final List<Pair<String, Nodeid>> bm = r.bookmarks();
+		r.getPhases();
+		final Iterable<Pair<String, Nodeid>> bm = r.getBookmarks();
 		for (Pair<String, Nodeid> pair : bm) {
 			System.out.println(pair);
 		}
@@ -600,6 +629,35 @@
 		}
 	}
 	
+	private boolean pushkey(String namespace, String key, String oldValue, String newValue) throws HgRemoteConnectionException, HgRuntimeException {
+		HttpURLConnection c = null;
+		try {
+			final String p = String.format("%s?cmd=pushkey&namespace=%s&key=%s&old=%s&new=&s", url.getPath(), namespace, key, oldValue, newValue);
+			URL u = new URL(url, p);
+			c = setupConnection(u.openConnection());
+			c.connect();
+			if (debug) {
+				dumpResponseHeader(u, c);
+			}
+			checkResponseOk(c, key, "pushkey");
+			final InputStream is = c.getInputStream();
+			int rv = is.read();
+			if (is.read() != -1) {
+				sessionContext.getLog().dump(getClass(), Severity.Error, "Unexpected data in response to pushkey");
+			}
+			is.close();
+			return rv == '1';
+		} catch (MalformedURLException ex) {
+			throw new HgRemoteConnectionException("Bad URL", ex).setRemoteCommand("pushkey").setServerInfo(getLocation());
+		} catch (IOException ex) {
+			throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand("pushkey").setServerInfo(getLocation());
+		} finally {
+			if (c != null) {
+				c.disconnect();
+			}
+		}
+	}
+	
 	private void checkResponseOk(HttpURLConnection c, String opName, String remoteCmd) throws HgRemoteConnectionException, IOException {
 		if (c.getResponseCode() != 200) {
 			String m = c.getResponseMessage() == null ? "unknown reason" : c.getResponseMessage();
@@ -712,4 +770,45 @@
 			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));
 		}
 	}
+
+	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() {
+			assert !pub; 
+			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;
+		}
+	}
 }