Allow writing a NoteMap back to the repository

This is necessary to allow applications to wrap the note tree in
a commit and update the note branch with the new state.

Change-Id: Idbd7ead4a1b16ae2b64a30a4a01a29cfed548cdf
Signed-off-by: Shawn O. Pearce <spearce@spearce.org>
diff --git a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java
index 7a95ccd..e74af8c 100644
--- a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java
+++ b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java
@@ -184,6 +184,17 @@ public void tick(final int secDelta) {
 	}
 
 	/**
+	 * Set the author and committer using {@link #getClock()}.
+	 *
+	 * @param c
+	 *            the commit builder to store.
+	 */
+	public void setAuthorAndCommitter(org.eclipse.jgit.lib.CommitBuilder c) {
+		c.setAuthor(new PersonIdent(author, new Date(now)));
+		c.setCommitter(new PersonIdent(committer, new Date(now)));
+	}
+
+	/**
 	 * Create a new blob object in the repository.
 	 *
 	 * @param content
@@ -815,8 +826,7 @@ public RevCommit create() throws Exception {
 
 				c = new org.eclipse.jgit.lib.CommitBuilder();
 				c.setParentIds(parents);
-				c.setAuthor(new PersonIdent(author, new Date(now)));
-				c.setCommitter(new PersonIdent(committer, new Date(now)));
+				setAuthorAndCommitter(c);
 				c.setMessage(message);
 
 				ObjectId commitId;
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/notes/NoteMapTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/notes/NoteMapTest.java
index e1a6d9b..f22b020 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/notes/NoteMapTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/notes/NoteMapTest.java
@@ -43,7 +43,10 @@
 
 package org.eclipse.jgit.notes;
 
+import java.io.IOException;
+
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.MutableObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -51,6 +54,8 @@
 import org.eclipse.jgit.lib.RepositoryTestCase;
 import org.eclipse.jgit.revwalk.RevBlob;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.util.RawParseUtils;
 
 public class NoteMapTest extends RepositoryTestCase {
@@ -188,6 +193,59 @@ public void testGetCachedBytes() throws Exception {
 		assertEquals(exp, RawParseUtils.decode(act));
 	}
 
+	public void testWriteUnchangedFlat() throws Exception {
+		RevBlob a = tr.blob("a");
+		RevBlob b = tr.blob("b");
+		RevBlob data1 = tr.blob("data1");
+		RevBlob data2 = tr.blob("data2");
+
+		RevCommit r = tr.commit() //
+				.add(a.name(), data1) //
+				.add(b.name(), data2) //
+				.add(".gitignore", "") //
+				.add("zoo-animals.txt", "") //
+				.create();
+		tr.parseBody(r);
+
+		NoteMap map = NoteMap.read(reader, r);
+		assertTrue("has note for a", map.contains(a));
+		assertTrue("has note for b", map.contains(b));
+
+		RevCommit n = commitNoteMap(map);
+		assertNotSame("is new commit", r, n);
+		assertSame("same tree", r.getTree(), n.getTree());
+	}
+
+	public void testWriteUnchangedFanout2_38() throws Exception {
+		RevBlob a = tr.blob("a");
+		RevBlob b = tr.blob("b");
+		RevBlob data1 = tr.blob("data1");
+		RevBlob data2 = tr.blob("data2");
+
+		RevCommit r = tr.commit() //
+				.add(fanout(2, a.name()), data1) //
+				.add(fanout(2, b.name()), data2) //
+				.add(".gitignore", "") //
+				.add("zoo-animals.txt", "") //
+				.create();
+		tr.parseBody(r);
+
+		NoteMap map = NoteMap.read(reader, r);
+		assertTrue("has note for a", map.contains(a));
+		assertTrue("has note for b", map.contains(b));
+
+		// This is a non-lazy map, so we'll be looking at the leaf buckets.
+		RevCommit n = commitNoteMap(map);
+		assertNotSame("is new commit", r, n);
+		assertSame("same tree", r.getTree(), n.getTree());
+
+		// Use a lazy-map for the next round of the same test.
+		map = NoteMap.read(reader, r);
+		n = commitNoteMap(map);
+		assertNotSame("is new commit", r, n);
+		assertSame("same tree", r.getTree(), n.getTree());
+	}
+
 	public void testCreateFromEmpty() throws Exception {
 		RevBlob a = tr.blob("a");
 		RevBlob b = tr.blob("b");
@@ -226,6 +284,8 @@ public void testEditFlat() throws Exception {
 		RevCommit r = tr.commit() //
 				.add(a.name(), data1) //
 				.add(b.name(), data2) //
+				.add(".gitignore", "") //
+				.add("zoo-animals.txt", b) //
 				.create();
 		tr.parseBody(r);
 
@@ -250,6 +310,15 @@ public void testEditFlat() throws Exception {
 			id.setByte(1, p);
 			assertTrue("contains " + id, map.contains(id));
 		}
+
+		RevCommit n = commitNoteMap(map);
+		map = NoteMap.read(reader, n);
+		assertEquals(data2, map.get(a));
+		assertEquals(b, map.get(data1));
+		assertFalse("no b", map.contains(b));
+		assertFalse("no data2", map.contains(data2));
+		assertEquals(b, TreeWalk
+				.forPath(reader, "zoo-animals.txt", n.getTree()).getObjectId(0));
 	}
 
 	public void testEditFanout2_38() throws Exception {
@@ -261,6 +330,8 @@ public void testEditFanout2_38() throws Exception {
 		RevCommit r = tr.commit() //
 				.add(fanout(2, a.name()), data1) //
 				.add(fanout(2, b.name()), data2) //
+				.add(".gitignore", "") //
+				.add("zoo-animals.txt", b) //
 				.create();
 		tr.parseBody(r);
 
@@ -274,11 +345,46 @@ public void testEditFanout2_38() throws Exception {
 		assertEquals(b, map.get(data1));
 		assertFalse("no b", map.contains(b));
 		assertFalse("no data2", map.contains(data2));
+		RevCommit n = commitNoteMap(map);
 
 		map.set(a, null);
 		map.set(data1, null);
 		assertFalse("no a", map.contains(a));
 		assertFalse("no data1", map.contains(data1));
+
+		map = NoteMap.read(reader, n);
+		assertEquals(data2, map.get(a));
+		assertEquals(b, map.get(data1));
+		assertFalse("no b", map.contains(b));
+		assertFalse("no data2", map.contains(data2));
+		assertEquals(b, TreeWalk
+				.forPath(reader, "zoo-animals.txt", n.getTree()).getObjectId(0));
+	}
+
+	public void testRemoveDeletesTreeFanout2_38() throws Exception {
+		RevBlob a = tr.blob("a");
+		RevBlob data1 = tr.blob("data1");
+		RevTree empty = tr.tree();
+
+		RevCommit r = tr.commit() //
+				.add(fanout(2, a.name()), data1) //
+				.create();
+		tr.parseBody(r);
+
+		NoteMap map = NoteMap.read(reader, r);
+		map.set(a, null);
+
+		RevCommit n = commitNoteMap(map);
+		assertEquals("empty tree", empty, n.getTree());
+	}
+
+	private RevCommit commitNoteMap(NoteMap map) throws IOException {
+		tr.tick(600);
+
+		CommitBuilder builder = new CommitBuilder();
+		builder.setTreeId(map.writeTree(inserter));
+		tr.setAuthorAndCommitter(builder);
+		return tr.getRevWalk().parseCommit(inserter.insert(builder));
 	}
 
 	private static String fanout(int prefix, String name) {
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/notes/FanoutBucket.java b/org.eclipse.jgit/src/org/eclipse/jgit/notes/FanoutBucket.java
index 7605286..2085676 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/notes/FanoutBucket.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/notes/FanoutBucket.java
@@ -43,12 +43,16 @@
 
 package org.eclipse.jgit.notes;
 
+import static org.eclipse.jgit.lib.FileMode.TREE;
+
 import java.io.IOException;
 
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.TreeFormatter;
 
 /**
  * A note tree holding only note subtrees, each named using a 2 digit hex name.
@@ -136,6 +140,43 @@ InMemoryNoteBucket set(AnyObjectId noteOn, AnyObjectId noteData,
 		}
 	}
 
+	private static final byte[] hexchar = { '0', '1', '2', '3', '4', '5', '6',
+			'7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' };
+
+	@Override
+	ObjectId writeTree(ObjectInserter inserter) throws IOException {
+		byte[] nameBuf = new byte[2];
+		TreeFormatter fmt = new TreeFormatter(treeSize());
+		NonNoteEntry e = nonNotes;
+
+		for (int cell = 0; cell < 256; cell++) {
+			NoteBucket b = table[cell];
+			if (b == null)
+				continue;
+
+			nameBuf[0] = hexchar[cell >>> 4];
+			nameBuf[1] = hexchar[cell & 0x0f];
+
+			while (e != null && e.pathCompare(nameBuf, 0, 2, TREE) < 0) {
+				e.format(fmt);
+				e = e.next;
+			}
+
+			fmt.append(nameBuf, 0, 2, TREE, b.writeTree(inserter));
+		}
+
+		for (; e != null; e = e.next)
+			e.format(fmt);
+		return fmt.insert(inserter);
+	}
+
+	private int treeSize() {
+		int sz = cnt * TreeFormatter.entrySize(TREE, 2);
+		for (NonNoteEntry e = nonNotes; e != null; e = e.next)
+			sz += e.treeEntrySize();
+		return sz;
+	}
+
 	private int cell(AnyObjectId id) {
 		return id.getByte(prefixLen >> 1);
 	}
@@ -158,6 +199,11 @@ InMemoryNoteBucket set(AnyObjectId noteOn, AnyObjectId noteData,
 			return load(noteOn, or).set(noteOn, noteData, or);
 		}
 
+		@Override
+		ObjectId writeTree(ObjectInserter inserter) {
+			return treeId;
+		}
+
 		private NoteBucket load(AnyObjectId objId, ObjectReader or)
 				throws IOException {
 			AbbreviatedObjectId p = objId.abbreviate(prefixLen + 2);
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/notes/LeafBucket.java b/org.eclipse.jgit/src/org/eclipse/jgit/notes/LeafBucket.java
index ce4feae..40be45f 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/notes/LeafBucket.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/notes/LeafBucket.java
@@ -43,11 +43,16 @@
 
 package org.eclipse.jgit.notes;
 
+import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
+import static org.eclipse.jgit.lib.FileMode.REGULAR_FILE;
+
 import java.io.IOException;
 
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.TreeFormatter;
 
 /**
  * A note tree holding only notes, with no subtrees.
@@ -126,6 +131,39 @@ InMemoryNoteBucket set(AnyObjectId noteOn, AnyObjectId noteData,
 		}
 	}
 
+	@Override
+	ObjectId writeTree(ObjectInserter inserter) throws IOException {
+		byte[] nameBuf = new byte[OBJECT_ID_STRING_LENGTH];
+		int nameLen = OBJECT_ID_STRING_LENGTH - prefixLen;
+		TreeFormatter fmt = new TreeFormatter(treeSize(nameLen));
+		NonNoteEntry e = nonNotes;
+
+		for (int i = 0; i < cnt; i++) {
+			Note n = notes[i];
+
+			n.copyTo(nameBuf, 0);
+
+			while (e != null
+					&& e.pathCompare(nameBuf, prefixLen, nameLen, REGULAR_FILE) < 0) {
+				e.format(fmt);
+				e = e.next;
+			}
+
+			fmt.append(nameBuf, prefixLen, nameLen, REGULAR_FILE, n.getData());
+		}
+
+		for (; e != null; e = e.next)
+			e.format(fmt);
+		return fmt.insert(inserter);
+	}
+
+	private int treeSize(final int nameLen) {
+		int sz = cnt * TreeFormatter.entrySize(REGULAR_FILE, nameLen);
+		for (NonNoteEntry e = nonNotes; e != null; e = e.next)
+			sz += e.treeEntrySize();
+		return sz;
+	}
+
 	void parseOneEntry(AnyObjectId noteOn, AnyObjectId noteData) {
 		growIfFull();
 		notes[cnt++] = new Note(noteOn, noteData.copy());
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/notes/NonNoteEntry.java b/org.eclipse.jgit/src/org/eclipse/jgit/notes/NonNoteEntry.java
index 20fd3a8..6a2d44b 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/notes/NonNoteEntry.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/notes/NonNoteEntry.java
@@ -68,4 +68,33 @@ class NonNoteEntry extends ObjectId {
 	void format(TreeFormatter fmt) {
 		fmt.append(name, mode, this);
 	}
+
+	int treeEntrySize() {
+		return TreeFormatter.entrySize(mode, name.length);
+	}
+
+	int pathCompare(byte[] bBuf, int bPos, int bLen, FileMode bMode) {
+		return pathCompare(name, 0, name.length, mode, //
+				bBuf, bPos, bLen, bMode);
+	}
+
+	private static int pathCompare(final byte[] aBuf, int aPos, final int aEnd,
+			final FileMode aMode, final byte[] bBuf, int bPos, final int bEnd,
+			final FileMode bMode) {
+		while (aPos < aEnd && bPos < bEnd) {
+			int cmp = (aBuf[aPos++] & 0xff) - (bBuf[bPos++] & 0xff);
+			if (cmp != 0)
+				return cmp;
+		}
+
+		if (aPos < aEnd)
+			return (aBuf[aPos] & 0xff) - lastPathChar(bMode);
+		if (bPos < bEnd)
+			return lastPathChar(aMode) - (bBuf[bPos] & 0xff);
+		return 0;
+	}
+
+	private static int lastPathChar(final FileMode mode) {
+		return FileMode.TREE.equals(mode.getBits()) ? '/' : '\0';
+	}
 }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteBucket.java b/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteBucket.java
index f75fc1f..f546514 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteBucket.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteBucket.java
@@ -47,6 +47,7 @@
 
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 
 /**
@@ -61,4 +62,6 @@ abstract ObjectId get(AnyObjectId objId, ObjectReader reader)
 
 	abstract InMemoryNoteBucket set(AnyObjectId noteOn, AnyObjectId noteData,
 			ObjectReader reader) throws IOException;
+
+	abstract ObjectId writeTree(ObjectInserter inserter) throws IOException;
 }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteMap.java b/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteMap.java
index 88ab481..6a7b5cf 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteMap.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteMap.java
@@ -309,6 +309,21 @@ public void remove(AnyObjectId noteOn) throws IOException {
 		set(noteOn, null);
 	}
 
+	/**
+	 * Write this note map as a tree.
+	 *
+	 * @param inserter
+	 *            inserter to use when writing trees to the object database.
+	 *            Caller is responsible for flushing the inserter before trying
+	 *            to read the objects, or exposing them through a reference.
+	 * @return the top level tree.
+	 * @throws IOException
+	 *             a tree could not be written.
+	 */
+	public ObjectId writeTree(ObjectInserter inserter) throws IOException {
+		return root.writeTree(inserter);
+	}
+
 	private void load(ObjectId rootTree) throws MissingObjectException,
 			IncorrectObjectTypeException, CorruptObjectException, IOException {
 		AbbreviatedObjectId none = AbbreviatedObjectId.fromString("");