changeset 645:14dac192aa26

Push: phase2 - upload bundle with changes to remote server
author Artem Tikhomirov <tikhomirov.artem@gmail.com>
date Thu, 20 Jun 2013 19:15:09 +0200
parents 1deea2f33218
children 3b7d51ed4c65
files src/org/tmatesoft/hg/core/HgPushCommand.java src/org/tmatesoft/hg/internal/BundleGenerator.java src/org/tmatesoft/hg/internal/DataSerializer.java src/org/tmatesoft/hg/internal/RepositoryComparator.java src/org/tmatesoft/hg/internal/RevlogStreamWriter.java src/org/tmatesoft/hg/repo/HgBundle.java src/org/tmatesoft/hg/repo/HgParentChildMap.java src/org/tmatesoft/hg/repo/HgRemoteRepository.java
diffstat 8 files changed, 341 insertions(+), 68 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/core/HgPushCommand.java	Thu Jun 20 19:15:09 2013 +0200
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 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.core;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+
+import org.tmatesoft.hg.internal.BundleGenerator;
+import org.tmatesoft.hg.internal.RepositoryComparator;
+import org.tmatesoft.hg.repo.HgBundle;
+import org.tmatesoft.hg.repo.HgChangelog;
+import org.tmatesoft.hg.repo.HgInternals;
+import org.tmatesoft.hg.repo.HgInvalidStateException;
+import org.tmatesoft.hg.repo.HgLookup;
+import org.tmatesoft.hg.repo.HgParentChildMap;
+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.ProgressSupport;
+
+/**
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+public class HgPushCommand extends HgAbstractCommand<HgPushCommand> {
+	
+	private final HgRepository repo;
+	private HgRemoteRepository remoteRepo;
+
+	public HgPushCommand(HgRepository hgRepo) {
+		repo = hgRepo;
+	}
+	
+	public HgPushCommand destination(HgRemoteRepository hgRemote) {
+		remoteRepo = hgRemote;
+		return this;
+	}
+
+	public void execute() throws HgRemoteConnectionException, HgIOException, CancelledException, HgLibraryFailureException {
+		final ProgressSupport progress = getProgressSupport(null);
+		try {
+			progress.start(100);
+			//
+			// find out missing
+			// TODO refactor same code in HgOutgoingCommand #getComparator and #getParentHelper
+			final HgParentChildMap<HgChangelog> parentHelper = new HgParentChildMap<HgChangelog>(repo.getChangelog());
+			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));
+			File bundleFile = bg.create(l);
+			progress.worked(20);
+			HgBundle b = new HgLookup(repo.getSessionContext()).loadBundle(bundleFile);
+			//
+			// send changes
+			remoteRepo.unbundle(b, comparator.getRemoteHeads());
+			progress.worked(20);
+			//
+			// FIXME update phase information
+//			remote.listkeys("phases");
+			progress.worked(5);
+			//
+			// FIXME update bookmark information
+//			remote.listkeys("bookmarks");
+			progress.worked(5);
+		} catch (IOException ex) {
+			throw new HgIOException(ex.getMessage(), null); // XXX not a nice idea to throw IOException from BundleGenerator#create
+		} catch (HgRepositoryNotFoundException ex) {
+			final HgInvalidStateException e = new HgInvalidStateException("Failed to load a just-created bundle");
+			e.initCause(ex);
+			throw new HgLibraryFailureException(e);
+		} catch (HgRuntimeException ex) {
+			throw new HgLibraryFailureException(ex);
+		} finally {
+			progress.done();
+		}
+	}
+	
+	/*
+	 * To test, start a server:
+	 * $ hg --config web.allow_push=* --config web.push_ssl=False --config server.validate=True --debug serve
+	 */
+	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/");
+		HgRemoteRepository rr = hgLookup.detect(new URL("http://localhost:8000/"));
+		new HgPushCommand(r).destination(rr).execute();
+	}
+}
--- a/src/org/tmatesoft/hg/internal/BundleGenerator.java	Wed Jun 19 16:04:24 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/BundleGenerator.java	Thu Jun 20 19:15:09 2013 +0200
@@ -22,17 +22,18 @@
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
-import org.tmatesoft.hg.console.Bundle;
 import org.tmatesoft.hg.core.HgIOException;
 import org.tmatesoft.hg.core.Nodeid;
+import org.tmatesoft.hg.internal.DataSerializer.OutputStreamSerializer;
 import org.tmatesoft.hg.internal.Patch.PatchDataSource;
 import org.tmatesoft.hg.repo.HgBundle;
 import org.tmatesoft.hg.repo.HgChangelog;
@@ -72,14 +73,17 @@
 		final IntVector manifestRevs = new IntVector(changesets.size(), 0);
 		final List<HgDataFile> files = new ArrayList<HgDataFile>();
 		clog.range(new HgChangelog.Inspector() {
+			private Set<String> seenFiles = new HashSet<String>();
 			public void next(int revisionIndex, Nodeid nodeid, RawChangeset cset) throws HgRuntimeException {
 				clogMap.put(revisionIndex, nodeid);
 				manifestRevs.add(manifest.getRevisionIndex(cset.manifest()));
 				for (String f : cset.files()) {
+					if (seenFiles.contains(f)) {
+						continue;
+					}
+					seenFiles.add(f);
 					HgDataFile df = repo.getRepo().getFileNode(f);
-					if (!files.contains(df)) {
-						files.add(df);
-					}
+					files.add(df);
 				}
 			}
 		}, clogRevs);
@@ -106,7 +110,8 @@
 		///////////////
 		//
 		final File bundleFile = File.createTempFile("hg4j-", "bundle");
-		final OutputStreamSerializer outRaw = new OutputStreamSerializer(new FileOutputStream(bundleFile));
+		final FileOutputStream osBundle = new FileOutputStream(bundleFile);
+		final OutputStreamSerializer outRaw = new OutputStreamSerializer(osBundle);
 		outRaw.write("HG10UN".getBytes(), 0, 6);
 		//
 		RevlogStream clogStream = repo.getImplAccess().getChangelogStream();
@@ -140,7 +145,10 @@
 				outRaw.writeInt(0); // null chunk for file group
 			}
 		}
+		outRaw.writeInt(0); // null chunk to indicate no more files (although BundleFormat page doesn't mention this)
 		outRaw.done();
+		osBundle.flush();
+		osBundle.close();
 		//return new HgBundle(repo.getSessionContext(), repo.getDataAccess(), bundleFile);
 		return bundleFile;
 	}
@@ -235,30 +243,4 @@
 			}
 		}
 	}
-	
-	private static class OutputStreamSerializer extends DataSerializer {
-		private final OutputStream out;
-		public OutputStreamSerializer(OutputStream outputStream) {
-			out = outputStream;
-		}
-
-		@Override
-		public void write(byte[] data, int offset, int length) throws HgIOException {
-			try {
-				out.write(data, offset, length);
-			} catch (IOException ex) {
-				throw new HgIOException(ex.getMessage(), ex, null);
-			}
-		}
-
-		@Override
-		public void done() throws HgIOException {
-			try {
-				out.close();
-				super.done();
-			} catch (IOException ex) {
-				throw new HgIOException(ex.getMessage(), ex, null);
-			}
-		}
-	}
 }
--- a/src/org/tmatesoft/hg/internal/DataSerializer.java	Wed Jun 19 16:04:24 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/DataSerializer.java	Thu Jun 20 19:15:09 2013 +0200
@@ -17,6 +17,8 @@
 package org.tmatesoft.hg.internal;
 
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
 
 import org.tmatesoft.hg.core.HgIOException;
 import org.tmatesoft.hg.repo.HgRuntimeException;
@@ -74,7 +76,7 @@
 	 * Denotes an entity that wants to/could be serialized
 	 */
 	@Experimental(reason="Work in progress")
-	interface DataSource {
+	public interface DataSource {
 		/**
 		 * Invoked once for a single write operation, 
 		 * although the source itself may get serialized several times
@@ -107,7 +109,10 @@
 		}
 	}
 	
-	public static class ByteArrayDataSerializer extends DataSerializer {
+	/**
+	 * Serialize data to byte array
+	 */
+	public static class ByteArraySerializer extends DataSerializer {
 		private final ByteArrayOutputStream out = new ByteArrayOutputStream();
 
 		@Override
@@ -119,4 +124,26 @@
 			return out.toByteArray();
 		}
 	}
+
+	/**
+	 * Bridge to the world of {@link java.io.OutputStream}.
+	 * Caller instantiates the stream and is responsible to close it as appropriate, 
+	 * {@link #done() DataSerializer.done()} doesn't close the stream. 
+	 */
+	public static class OutputStreamSerializer extends DataSerializer {
+		private final OutputStream out;
+
+		public OutputStreamSerializer(OutputStream outputStream) {
+			out = outputStream;
+		}
+
+		@Override
+		public void write(byte[] data, int offset, int length) throws HgIOException {
+			try {
+				out.write(data, offset, length);
+			} catch (IOException ex) {
+				throw new HgIOException(ex.getMessage(), ex, null);
+			}
+		}
+	}
 }
--- a/src/org/tmatesoft/hg/internal/RepositoryComparator.java	Wed Jun 19 16:04:24 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/RepositoryComparator.java	Thu Jun 20 19:15:09 2013 +0200
@@ -54,6 +54,7 @@
 	private final HgParentChildMap<HgChangelog> localRepo;
 	private final HgRemoteRepository remoteRepo;
 	private List<Nodeid> common;
+	private List<Nodeid> remoteHeads;
 
 	public RepositoryComparator(HgParentChildMap<HgChangelog> pwLocal, HgRemoteRepository hgRemote) {
 		localRepo = pwLocal;
@@ -81,11 +82,21 @@
 		return common;
 	}
 	
+	public List<Nodeid> getRemoteHeads() {
+		assert remoteHeads != null;
+		return remoteHeads;
+	}
+	
 	/**
 	 * @return revisions that are children of common entries, i.e. revisions that are present on the local server and not on remote.
 	 */
 	public List<Nodeid> getLocalOnlyRevisions() {
-		return localRepo.childrenOf(getCommon());
+		final List<Nodeid> c = getCommon();
+		if (c.isEmpty()) {
+			return localRepo.all();
+		} else {
+			return localRepo.childrenOf(c);
+		}
 	}
 	
 	/**
@@ -128,7 +139,7 @@
 	}
 
 	private List<Nodeid> findCommonWithRemote() throws HgRemoteConnectionException {
-		List<Nodeid> remoteHeads = remoteRepo.heads();
+		remoteHeads = remoteRepo.heads();
 		LinkedList<Nodeid> resultCommon = new LinkedList<Nodeid>(); // these remotes are known in local
 		LinkedList<Nodeid> toQuery = new LinkedList<Nodeid>(); // these need further queries to find common
 		for (Nodeid rh : remoteHeads) {
--- a/src/org/tmatesoft/hg/internal/RevlogStreamWriter.java	Wed Jun 19 16:04:24 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/RevlogStreamWriter.java	Thu Jun 20 19:15:09 2013 +0200
@@ -25,7 +25,7 @@
 import org.tmatesoft.hg.core.HgIOException;
 import org.tmatesoft.hg.core.Nodeid;
 import org.tmatesoft.hg.core.SessionContext;
-import org.tmatesoft.hg.internal.DataSerializer.ByteArrayDataSerializer;
+import org.tmatesoft.hg.internal.DataSerializer.ByteArraySerializer;
 import org.tmatesoft.hg.internal.DataSerializer.ByteArrayDataSource;
 import org.tmatesoft.hg.internal.DataSerializer.DataSource;
 import org.tmatesoft.hg.repo.HgInvalidControlFileException;
@@ -142,7 +142,7 @@
 	}
 	
 	private byte[] toByteArray(DataSource content) throws HgIOException, HgRuntimeException {
-		ByteArrayDataSerializer ba = new ByteArrayDataSerializer();
+		ByteArraySerializer ba = new ByteArraySerializer();
 		content.serialize(ba);
 		return ba.toByteArray();
 	}
--- a/src/org/tmatesoft/hg/repo/HgBundle.java	Wed Jun 19 16:04:24 2013 +0200
+++ b/src/org/tmatesoft/hg/repo/HgBundle.java	Thu Jun 20 19:15:09 2013 +0200
@@ -17,9 +17,11 @@
 package org.tmatesoft.hg.repo;
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
 import java.util.ConcurrentModificationException;
 
+import org.tmatesoft.hg.core.HgIOException;
 import org.tmatesoft.hg.core.Nodeid;
 import org.tmatesoft.hg.core.SessionContext;
 import org.tmatesoft.hg.internal.ByteArrayChannel;
@@ -27,8 +29,10 @@
 import org.tmatesoft.hg.internal.Callback;
 import org.tmatesoft.hg.internal.DataAccess;
 import org.tmatesoft.hg.internal.DataAccessProvider;
+import org.tmatesoft.hg.internal.DataSerializer;
 import org.tmatesoft.hg.internal.DigestHelper;
 import org.tmatesoft.hg.internal.Experimental;
+import org.tmatesoft.hg.internal.FileUtils;
 import org.tmatesoft.hg.internal.InflaterDataAccess;
 import org.tmatesoft.hg.internal.Internals;
 import org.tmatesoft.hg.internal.Lifecycle;
@@ -50,11 +54,11 @@
 
 	private final File bundleFile;
 	private final DataAccessProvider accessProvider;
-//	private final SessionContext sessionContext;
+	private final SessionContext ctx;
 	private Lifecycle.BasicCallback flowControl;
 
-	HgBundle(SessionContext ctx, DataAccessProvider dap, File bundle) {
-//		sessionContext = ctx;
+	HgBundle(SessionContext sessionContext, DataAccessProvider dap, File bundle) {
+		ctx = sessionContext;
 		accessProvider = dap;
 		bundleFile = bundle;
 	}
@@ -533,4 +537,29 @@
 			return String.format("%s %s %s %s; patches:%d\n", node().shortNotation(), firstParent().shortNotation(), secondParent().shortNotation(), cset().shortNotation(), patchCount);
 		}
 	}
+
+	@Experimental(reason="Work in progress, not an API")
+	public class BundleSerializer implements DataSerializer.DataSource {
+
+		public void serialize(DataSerializer out) throws HgIOException, HgRuntimeException {
+			FileInputStream fis = null;
+			try {
+				fis = new FileInputStream(HgBundle.this.bundleFile);
+				byte[] buffer = new byte[8*1024];
+				int r;
+				while ((r = fis.read(buffer, 0, buffer.length)) > 0) {
+					out.write(buffer, 0, r);
+				}
+				
+			} catch (IOException ex) {
+				throw new HgIOException("Failed to serialize bundle", HgBundle.this.bundleFile);
+			} finally {
+				new FileUtils(HgBundle.this.ctx.getLog()).closeQuietly(fis, HgBundle.this.bundleFile);
+			}
+		}
+
+		public int serializeLength() throws HgRuntimeException {
+			return Internals.ltoi(HgBundle.this.bundleFile.length());
+		}
+	}
 }
--- a/src/org/tmatesoft/hg/repo/HgParentChildMap.java	Wed Jun 19 16:04:24 2013 +0200
+++ b/src/org/tmatesoft/hg/repo/HgParentChildMap.java	Thu Jun 20 19:15:09 2013 +0200
@@ -20,6 +20,7 @@
 
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
@@ -56,7 +57,6 @@
  */
 public final class HgParentChildMap<T extends Revlog> implements ParentInspector {
 
-	
 	private Nodeid[] sequential; // natural repository order, childrenOf rely on ordering
 	private Nodeid[] sorted; // for binary search
 	private int[] sorted2natural;
@@ -180,6 +180,9 @@
 	// @return ordered collection of all children rooted at supplied nodes. Nodes shall not be descendants of each other!
 	// Nodeids shall belong to this revlog
 	public List<Nodeid> childrenOf(List<Nodeid> roots) {
+		if (roots.isEmpty()) {
+			return Collections.emptyList();
+		}
 		HashSet<Nodeid> parents = new HashSet<Nodeid>();
 		LinkedList<Nodeid> result = new LinkedList<Nodeid>();
 		int earliestRevision = Integer.MAX_VALUE;
@@ -244,4 +247,11 @@
 		}
 		return false;
 	}
+
+	/**
+	 * @return all revisions this map knows about
+	 */
+	public List<Nodeid> all() {
+		return Arrays.asList(sequential);
+	}
 }
\ No newline at end of file
--- a/src/org/tmatesoft/hg/repo/HgRemoteRepository.java	Wed Jun 19 16:04:24 2013 +0200
+++ b/src/org/tmatesoft/hg/repo/HgRemoteRepository.java	Thu Jun 20 19:15:09 2013 +0200
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2011-2012 TMate Software Ltd
+ * 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
@@ -19,6 +19,7 @@
 import static org.tmatesoft.hg.util.LogFacility.Severity.Info;
 
 import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
@@ -26,6 +27,8 @@
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.StreamTokenizer;
+import java.net.ContentHandler;
+import java.net.ContentHandlerFactory;
 import java.net.HttpURLConnection;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -53,10 +56,14 @@
 import javax.net.ssl.X509TrustManager;
 
 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.DataSerializer;
+import org.tmatesoft.hg.internal.Internals;
+import org.tmatesoft.hg.internal.DataSerializer.OutputStreamSerializer;
 import org.tmatesoft.hg.internal.PropertyMarshal;
 
 /**
@@ -77,6 +84,33 @@
 	private final SessionContext sessionContext;
 	private Set<String> remoteCapabilities;
 	
+	static {
+		URLConnection.setContentHandlerFactory(new ContentHandlerFactory() {
+			
+			public ContentHandler createContentHandler(String mimetype) {
+				if ("application/mercurial-0.1".equals(mimetype)) {
+					return new ContentHandler() {
+						
+						@Override
+						public Object getContent(URLConnection urlc) throws IOException {
+							if (urlc.getContentLength() > 0) {
+								ByteArrayOutputStream bos = new ByteArrayOutputStream();
+								InputStream is = urlc.getInputStream();
+								int r;
+								while ((r = is.read()) != -1) {
+									bos.write(r);
+								}
+								return new String(bos.toByteArray());
+							}
+							return "<empty>";
+						}
+					};
+				}
+				return null;
+			}
+		});
+	}
+	
 	HgRemoteRepository(SessionContext ctx, URL url) throws HgBadArgumentException {
 		if (url == null || ctx == null) {
 			throw new IllegalArgumentException();
@@ -192,9 +226,10 @@
 	}
 
 	public List<Nodeid> heads() throws HgRemoteConnectionException {
+		HttpURLConnection c = null;
 		try {
 			URL u = new URL(url, url.getPath() + "?cmd=heads");
-			HttpURLConnection c = setupConnection(u.openConnection());
+			c = setupConnection(u.openConnection());
 			c.connect();
 			if (debug) {
 				dumpResponseHeader(u, c);
@@ -213,6 +248,10 @@
 			throw new HgRemoteConnectionException("Bad URL", ex).setRemoteCommand("heads").setServerInfo(getLocation());
 		} catch (IOException ex) {
 			throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand("heads").setServerInfo(getLocation());
+		} finally {
+			if (c != null) {
+				c.disconnect();
+			}
 		}
 	}
 	
@@ -245,10 +284,11 @@
 			// strip last space 
 			sb.setLength(sb.length() - 1);
 		}
+		HttpURLConnection c = null;
 		try {
 			boolean usePOST = ranges.size() > 3;
 			URL u = new URL(url, url.getPath() + "?cmd=between" + (usePOST ? "" : '&' + sb.toString()));
-			HttpURLConnection c = setupConnection(u.openConnection());
+			c = setupConnection(u.openConnection());
 			if (usePOST) {
 				c.setRequestMethod("POST");
 				c.setRequestProperty("Content-Length", String.valueOf(sb.length()/*nodeids are ASCII, bytes == characters */));
@@ -314,23 +354,19 @@
 			throw new HgRemoteConnectionException("Bad URL", ex).setRemoteCommand("between").setServerInfo(getLocation());
 		} catch (IOException ex) {
 			throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand("between").setServerInfo(getLocation());
+		} finally {
+			if (c != null) {
+				c.disconnect();
+			}
 		}
 	}
 
 	public List<RemoteBranch> branches(List<Nodeid> nodes) throws HgRemoteConnectionException {
-		StringBuilder sb = new StringBuilder(20 + nodes.size() * 41);
-		sb.append("nodes=");
-		for (Nodeid n : nodes) {
-			sb.append(n.toString());
-			sb.append('+');
-		}
-		if (sb.charAt(sb.length() - 1) == '+') {
-			// strip last space 
-			sb.setLength(sb.length() - 1);
-		}
+		StringBuilder sb = appendNodeidListArgument("nodes", nodes, null);
+		HttpURLConnection c = null;
 		try {
 			URL u = new URL(url, url.getPath() + "?cmd=branches&" + sb.toString());
-			HttpURLConnection c = setupConnection(u.openConnection());
+			c = setupConnection(u.openConnection());
 			c.connect();
 			if (debug) {
 				dumpResponseHeader(u, c);
@@ -357,6 +393,10 @@
 			throw new HgRemoteConnectionException("Bad URL", ex).setRemoteCommand("branches").setServerInfo(getLocation());
 		} catch (IOException ex) {
 			throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand("branches").setServerInfo(getLocation());
+		} finally {
+			if (c != null) {
+				c.disconnect();
+			}
 		}
 	}
 
@@ -378,19 +418,11 @@
 	 */
 	public HgBundle getChanges(List<Nodeid> roots) throws HgRemoteConnectionException, HgRuntimeException {
 		List<Nodeid> _roots = roots.isEmpty() ? Collections.singletonList(Nodeid.NULL) : roots;
-		StringBuilder sb = new StringBuilder(20 + _roots.size() * 41);
-		sb.append("roots=");
-		for (Nodeid n : _roots) {
-			sb.append(n.toString());
-			sb.append('+');
-		}
-		if (sb.charAt(sb.length() - 1) == '+') {
-			// strip last space 
-			sb.setLength(sb.length() - 1);
-		}
+		StringBuilder sb = appendNodeidListArgument("roots", _roots, null);
+		HttpURLConnection c = null;
 		try {
 			URL u = new URL(url, url.getPath() + "?cmd=changegroup&" + sb.toString());
-			HttpURLConnection c = setupConnection(u.openConnection());
+			c = setupConnection(u.openConnection());
 			c.connect();
 			if (debug) {
 				dumpResponseHeader(u, c);
@@ -407,6 +439,54 @@
 			throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand("changegroup").setServerInfo(getLocation());
 		} catch (HgRepositoryNotFoundException ex) {
 			throw new HgRemoteConnectionException("Communication failure", ex).setRemoteCommand("changegroup").setServerInfo(getLocation());
+		} finally {
+			if (c != null) {
+				c.disconnect();
+			}
+		}
+	}
+	
+	public void unbundle(HgBundle bundle, List<Nodeid> heads) throws HgRemoteConnectionException, HgRuntimeException {
+		if (heads == 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))
+			throw Internals.notImplemented();
+		}
+		StringBuilder sb = appendNodeidListArgument("heads", heads, null);
+		
+		HttpURLConnection c = null;
+		DataSerializer.DataSource bundleData = bundle.new BundleSerializer();
+		try {
+			URL u = new URL(url, url.getPath() + "?cmd=unbundle&" + sb.toString());
+			c = setupConnection(u.openConnection());
+			c.setRequestMethod("POST");
+			c.setRequestProperty("Content-Length", String.valueOf(bundleData.serializeLength()));
+			c.setRequestProperty("Content-Type", "application/mercurial-0.1");
+			c.setDoOutput(true);
+			c.connect();
+			OutputStream os = c.getOutputStream();
+			bundleData.serialize(new OutputStreamSerializer(os));
+			os.flush();
+			os.close();
+			if (debug) {
+				dumpResponseHeader(u, c);
+				dumpResponse(c);
+			}
+			if (c.getResponseCode() != 200) {
+				String m = c.getResponseMessage() == null ? "unknown reason" : c.getResponseMessage();
+				String em = String.format("Push failed: %s (HTTP error:%d)", m, c.getResponseCode());
+				throw new HgRemoteConnectionException(em).setRemoteCommand("unbundle").setServerInfo(getLocation());
+			}
+		} catch (MalformedURLException ex) {
+			throw new HgRemoteConnectionException("Bad URL", ex).setRemoteCommand("unbundle").setServerInfo(getLocation());
+		} 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 {
+			if (c != null) {
+				c.disconnect();
+			}
 		}
 	}
 
@@ -423,7 +503,7 @@
 	}
 	
 	private HttpURLConnection setupConnection(URLConnection urlConnection) {
-		urlConnection.setRequestProperty("User-Agent", "hg4j/0.5.0");
+		urlConnection.setRequestProperty("User-Agent", "hg4j/1.0.0");
 		urlConnection.addRequestProperty("Accept", "application/mercurial-0.1");
 		if (authInfo != null) {
 			urlConnection.addRequestProperty("Authorization", "Basic " + authInfo);
@@ -433,6 +513,23 @@
 		}
 		return (HttpURLConnection) urlConnection;
 	}
+	
+	private StringBuilder appendNodeidListArgument(String key, List<Nodeid> values, StringBuilder sb) {
+		if (sb == null) {
+			sb = new StringBuilder(20 + values.size() * 41);
+		}
+		sb.append(key);
+		sb.append('=');
+		for (Nodeid n : values) {
+			sb.append(n.toString());
+			sb.append('+');
+		}
+		if (sb.charAt(sb.length() - 1) == '+') {
+			// strip last space 
+			sb.setLength(sb.length() - 1);
+		}
+		return sb;
+	}
 
 	private void dumpResponseHeader(URL u, HttpURLConnection c) {
 		System.out.printf("Query (%d bytes):%s\n", u.getQuery().length(), u.getQuery());
@@ -443,6 +540,13 @@
 		}
 	}
 	
+	private void dumpResponse(HttpURLConnection c) throws IOException {
+		if (c.getContentLength() > 0) {
+			final Object content = c.getContent();
+			System.out.println(content);
+		}
+	}
+	
 	private static File writeBundle(InputStream is, boolean decompress, String header) throws IOException {
 		InputStream zipStream = decompress ? new InflaterInputStream(is) : is;
 		File tf = File.createTempFile("hg-bundle-", null);