Allow to include untracked files in stash operations.

Unstashed changes are saved in a commit which is added as an additional
parent to the stash commit.
This behaviour is fully compatible with C Git stashing of untracked
files.

Bug: 434411
Change-Id: I2af784deb0c2320bb57bc4fd472a8daad8674e7d
Signed-off-by: Andreas Hermann <a.v.hermann@gmail.com>
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashApplyCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashApplyCommandTest.java
index 2834100..95b1419 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashApplyCommandTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashApplyCommandTest.java
@@ -608,7 +608,8 @@
 			fail("Exception not thrown");
 		} catch (JGitInternalException e) {
 			assertEquals(MessageFormat.format(
-					JGitText.get().stashCommitMissingTwoParents, head.name()),
+					JGitText.get().stashCommitIncorrectNumberOfParents,
+					head.name(), 0),
 					e.getMessage());
 		}
 	}
@@ -648,4 +649,91 @@
 
 		assertFalse(file.exists());
 	}
+
+	@Test
+	public void untrackedFileNotIncluded() throws Exception {
+		String untrackedPath = "untracked.txt";
+		File untrackedFile = writeTrashFile(untrackedPath, "content");
+		// at least one modification needed
+		writeTrashFile(PATH, "content2");
+		git.add().addFilepattern(PATH).call();
+		git.stashCreate().call();
+		assertTrue(untrackedFile.exists());
+
+		git.stashApply().setStashRef("stash@{0}").call();
+		assertTrue(untrackedFile.exists());
+
+		Status status = git.status().call();
+		assertEquals(1, status.getUntracked().size());
+		assertTrue(status.getUntracked().contains(untrackedPath));
+		assertEquals(1, status.getChanged().size());
+		assertTrue(status.getChanged().contains(PATH));
+		assertTrue(status.getAdded().isEmpty());
+		assertTrue(status.getConflicting().isEmpty());
+		assertTrue(status.getMissing().isEmpty());
+		assertTrue(status.getRemoved().isEmpty());
+		assertTrue(status.getModified().isEmpty());
+	}
+
+	@Test
+	public void untrackedFileIncluded() throws Exception {
+		String path = "a/b/untracked.txt";
+		File untrackedFile = writeTrashFile(path, "content");
+		RevCommit stashedCommit = git.stashCreate().setIncludeUntracked(true)
+				.call();
+		assertNotNull(stashedCommit);
+		assertFalse(untrackedFile.exists());
+		deleteTrashFile("a/b"); // checkout should create parent dirs
+
+		git.stashApply().setStashRef("stash@{0}").call();
+		assertTrue(untrackedFile.exists());
+		assertEquals("content", read(path));
+
+		Status status = git.status().call();
+		assertEquals(1, status.getUntracked().size());
+		assertTrue(status.getAdded().isEmpty());
+		assertTrue(status.getChanged().isEmpty());
+		assertTrue(status.getConflicting().isEmpty());
+		assertTrue(status.getMissing().isEmpty());
+		assertTrue(status.getRemoved().isEmpty());
+		assertTrue(status.getModified().isEmpty());
+		assertTrue(status.getUntracked().contains(path));
+	}
+
+	@Test
+	public void untrackedFileConflictsWithCommit() throws Exception {
+		String path = "untracked.txt";
+		writeTrashFile(path, "untracked");
+		git.stashCreate().setIncludeUntracked(true).call();
+
+		writeTrashFile(path, "committed");
+		head = git.commit().setMessage("add file").call();
+		git.add().addFilepattern(path).call();
+		git.commit().setMessage("conflicting commit").call();
+
+		try {
+			git.stashApply().setStashRef("stash@{0}").call();
+			fail("StashApplyFailureException should be thrown.");
+		} catch (StashApplyFailureException e) {
+			assertEquals(e.getMessage(), JGitText.get().stashApplyConflict);
+		}
+		assertEquals("committed", read(path));
+	}
+
+	@Test
+	public void untrackedFileConflictsWithWorkingDirectory()
+			throws Exception {
+		String path = "untracked.txt";
+		writeTrashFile(path, "untracked");
+		git.stashCreate().setIncludeUntracked(true).call();
+
+		writeTrashFile(path, "working-directory");
+		try {
+			git.stashApply().setStashRef("stash@{0}").call();
+			fail("StashApplyFailureException should be thrown.");
+		} catch (StashApplyFailureException e) {
+			assertEquals(e.getMessage(), JGitText.get().stashApplyConflict);
+		}
+		assertEquals("working-directory", read(path));
+	}
 }
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashCreateCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashCreateCommandTest.java
index 030dc9f..3871203 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashCreateCommandTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashCreateCommandTest.java
@@ -80,6 +80,8 @@
 
 	private File committedFile;
 
+	private File untrackedFile;
+
 	@Before
 	public void setUp() throws Exception {
 		super.setUp();
@@ -88,16 +90,24 @@
 		git.add().addFilepattern("file.txt").call();
 		head = git.commit().setMessage("add file").call();
 		assertNotNull(head);
-		writeTrashFile("untracked.txt", "content");
+		untrackedFile = writeTrashFile("untracked.txt", "content");
+	}
+
+	private void validateStashedCommit(final RevCommit commit)
+			throws IOException {
+		validateStashedCommit(commit, 2);
 	}
 
 	/**
 	 * Core validation to be performed on all stashed commits
 	 *
 	 * @param commit
+	 * @param parentCount
+	 *            number of parent commits required
 	 * @throws IOException
 	 */
-	private void validateStashedCommit(final RevCommit commit)
+	private void validateStashedCommit(final RevCommit commit,
+			int parentCount)
 			throws IOException {
 		assertNotNull(commit);
 		Ref stashRef = db.getRef(Constants.R_STASH);
@@ -105,7 +115,7 @@
 		assertEquals(commit, stashRef.getObjectId());
 		assertNotNull(commit.getAuthorIdent());
 		assertEquals(commit.getAuthorIdent(), commit.getCommitterIdent());
-		assertEquals(2, commit.getParentCount());
+		assertEquals(parentCount, commit.getParentCount());
 
 		// Load parents
 		RevWalk walk = new RevWalk(db);
@@ -461,4 +471,35 @@
 
 		git.stashCreate().call();
 	}
+
+	@Test
+	public void untrackedFileIncluded() throws Exception {
+		String trackedPath = "tracked.txt";
+		writeTrashFile(trackedPath, "content2");
+		git.add().addFilepattern(trackedPath).call();
+
+		RevCommit stashed = git.stashCreate()
+				.setIncludeUntracked(true).call();
+		validateStashedCommit(stashed, 3);
+
+		assertEquals(
+				"Expected commits for workingDir,stashedIndex and untrackedFiles.",
+				3, stashed.getParentCount());
+		assertFalse("untracked file should be deleted.", untrackedFile.exists());
+	}
+
+	@Test
+	public void untrackedFileNotIncluded() throws Exception {
+		String trackedPath = "tracked.txt";
+		// at least one modification needed
+		writeTrashFile(trackedPath, "content2");
+		git.add().addFilepattern(trackedPath).call();
+
+		RevCommit stashed = git.stashCreate().call();
+		validateStashedCommit(stashed);
+
+		assertTrue("untracked file should be left untouched.",
+				untrackedFile.exists());
+		assertEquals("content", read(untrackedFile));
+	}
 }
diff --git a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
index 7b07321..fd5801e 100644
--- a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
+++ b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
@@ -476,7 +476,7 @@
 stashApplyFailed=Applying stashed changes did not successfully complete
 stashApplyOnUnsafeRepository=Cannot apply stashed commit on a repository with state: {0}
 stashApplyWithoutHead=Cannot apply stashed commit in an empty repository or onto an unborn branch
-stashCommitMissingTwoParents=Stashed commit ''{0}'' does not have two parent commits
+stashCommitIncorrectNumberOfParents=Stashed commit ''{0}'' does have {1} parent commits instead of 2 or 3.
 stashDropDeleteRefFailed=Deleting stash reference failed with result: {0}
 stashDropFailed=Dropping stashed commit failed
 stashDropMissingReflog=Stash reflog does not contain entry ''{0}''
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java
index f73ce83..d935857 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java
@@ -42,6 +42,7 @@
  */
 package org.eclipse.jgit.api;
 
+import java.io.File;
 import java.io.IOException;
 import java.text.MessageFormat;
 
@@ -56,6 +57,7 @@
 import org.eclipse.jgit.dircache.DirCacheCheckout;
 import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.dircache.DirCacheIterator;
+import org.eclipse.jgit.errors.CheckoutConflictException;
 import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -90,6 +92,8 @@
 
 	private boolean applyIndex = true;
 
+	private boolean applyUntracked = true;
+
 	private boolean ignoreRepositoryState;
 
 	private MergeStrategy strategy = MergeStrategy.RECURSIVE;
@@ -173,15 +177,20 @@
 
 			final ObjectId stashId = getStashId();
 			RevCommit stashCommit = revWalk.parseCommit(stashId);
-			if (stashCommit.getParentCount() != 2)
+			if (stashCommit.getParentCount() < 2
+					|| stashCommit.getParentCount() > 3)
 				throw new JGitInternalException(MessageFormat.format(
-						JGitText.get().stashCommitMissingTwoParents,
-						stashId.name()));
+						JGitText.get().stashCommitIncorrectNumberOfParents,
+						stashId.name(),
+						Integer.valueOf(stashCommit.getParentCount())));
 
 			ObjectId headTree = repo.resolve(Constants.HEAD + "^{tree}"); //$NON-NLS-1$
 			ObjectId stashIndexCommit = revWalk.parseCommit(stashCommit
 					.getParent(1));
 			ObjectId stashHeadCommit = stashCommit.getParent(0);
+			ObjectId untrackedCommit = null;
+			if (applyUntracked && stashCommit.getParentCount() == 3)
+				untrackedCommit = revWalk.parseCommit(stashCommit.getParent(2));
 
 			ResolveMerger merger = (ResolveMerger) strategy.newMerger(repo);
 			merger.setCommitNames(new String[] { "stashed HEAD", "HEAD",
@@ -209,6 +218,29 @@
 								JGitText.get().stashApplyConflict);
 					}
 				}
+
+				if (untrackedCommit != null) {
+					ResolveMerger untrackedMerger = (ResolveMerger) strategy
+							.newMerger(repo, true);
+					untrackedMerger.setCommitNames(new String[] {
+							"stashed HEAD", "HEAD", "untracked files" }); //$NON-NLS-1$//$NON-NLS-2$//$NON-NLS-3$
+					untrackedMerger.setBase(stashHeadCommit);
+					boolean ok = untrackedMerger.merge(headCommit,
+							untrackedCommit);
+					if (ok)
+						try {
+							RevTree untrackedTree = revWalk
+									.parseTree(untrackedMerger
+											.getResultTreeId());
+							resetUntracked(untrackedTree);
+						} catch (CheckoutConflictException e) {
+							throw new StashApplyFailureException(
+									JGitText.get().stashApplyConflict);
+						}
+					else
+						throw new StashApplyFailureException(
+								JGitText.get().stashApplyConflict);
+				}
 			} else {
 				throw new StashApplyFailureException(
 						JGitText.get().stashApplyConflict);
@@ -244,6 +276,15 @@
 		return this;
 	}
 
+	/**
+	 * @param applyUntracked
+	 *            true (default) if the command should restore untracked files
+	 * @since 3.4
+	 */
+	public void setApplyUntracked(boolean applyUntracked) {
+		this.applyUntracked = applyUntracked;
+	}
+
 	private void resetIndex(RevTree tree) throws IOException {
 		DirCache dc = repo.lockDirCache();
 		TreeWalk walk = null;
@@ -285,4 +326,55 @@
 				walk.release();
 		}
 	}
+
+	private void resetUntracked(RevTree tree) throws CheckoutConflictException,
+			IOException {
+		TreeWalk walk = null;
+		try {
+			walk = new TreeWalk(repo); // maybe NameConflictTreeWalk?
+			walk.addTree(tree);
+			walk.addTree(new FileTreeIterator(repo));
+			walk.setRecursive(true);
+
+			final ObjectReader reader = walk.getObjectReader();
+
+			while (walk.next()) {
+				final AbstractTreeIterator cIter = walk.getTree(0,
+						AbstractTreeIterator.class);
+				if (cIter == null)
+					// Not in commit, don't create untracked
+					continue;
+
+				final DirCacheEntry entry = new DirCacheEntry(walk.getRawPath());
+				entry.setFileMode(cIter.getEntryFileMode());
+				entry.setObjectIdFromRaw(cIter.idBuffer(), cIter.idOffset());
+
+				FileTreeIterator fIter = walk
+						.getTree(1, FileTreeIterator.class);
+				if (fIter != null) {
+					if (fIter.isModified(entry, true, reader)) {
+						// file exists and is dirty
+						throw new CheckoutConflictException(
+								entry.getPathString());
+					}
+				}
+
+				checkoutPath(entry, reader);
+			}
+		} finally {
+			if (walk != null)
+				walk.release();
+		}
+	}
+
+	private void checkoutPath(DirCacheEntry entry, ObjectReader reader) {
+		try {
+			File file = new File(repo.getWorkTree(), entry.getPathString());
+			DirCacheCheckout.checkoutEntry(repo, file, entry, reader);
+		} catch (IOException e) {
+			throw new JGitInternalException(MessageFormat.format(
+					JGitText.get().checkoutConflictWithFile,
+					entry.getPathString()), e);
+		}
+	}
 }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashCreateCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashCreateCommand.java
index cf0b6d1..af35f77 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashCreateCommand.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashCreateCommand.java
@@ -42,6 +42,7 @@
  */
 package org.eclipse.jgit.api;
 
+import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.text.MessageFormat;
@@ -54,6 +55,7 @@
 import org.eclipse.jgit.api.errors.NoHeadException;
 import org.eclipse.jgit.api.errors.UnmergedPathsException;
 import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
 import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
 import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
@@ -80,6 +82,7 @@
 import org.eclipse.jgit.treewalk.filter.AndTreeFilter;
 import org.eclipse.jgit.treewalk.filter.IndexDiffFilter;
 import org.eclipse.jgit.treewalk.filter.SkipWorkTreeFilter;
+import org.eclipse.jgit.util.FileUtils;
 
 /**
  * Command class to stash changes in the working directory and index in a
@@ -93,6 +96,8 @@
 
 	private static final String MSG_INDEX = "index on {0}: {1} {2}";
 
+	private static final String MSG_UNTRACKED = "untracked files on {0}: {1} {2}";
+
 	private static final String MSG_WORKING_DIR = "WIP on {0}: {1} {2}";
 
 	private String indexMessage = MSG_INDEX;
@@ -103,6 +108,8 @@
 
 	private PersonIdent person;
 
+	private boolean includeUntracked;
+
 	/**
 	 * Create a command to stash changes in the working directory and index
 	 *
@@ -166,6 +173,18 @@
 		return this;
 	}
 
+	/**
+	 * Whether to include untracked files in the stash.
+	 *
+	 * @param includeUntracked
+	 * @return {@code this}
+	 * @since 3.4
+	 */
+	public StashCreateCommand setIncludeUntracked(boolean includeUntracked) {
+		this.includeUntracked = includeUntracked;
+		return this;
+	}
+
 	private RevCommit parseCommit(final ObjectReader reader,
 			final ObjectId headId) throws IOException {
 		final RevWalk walk = new RevWalk(reader);
@@ -173,14 +192,13 @@
 		return walk.parseCommit(headId);
 	}
 
-	private CommitBuilder createBuilder(ObjectId headId) {
+	private CommitBuilder createBuilder() {
 		CommitBuilder builder = new CommitBuilder();
 		PersonIdent author = person;
 		if (author == null)
 			author = new PersonIdent(repo);
 		builder.setAuthor(author);
 		builder.setCommitter(author);
-		builder.setParentId(headId);
 		return builder;
 	}
 
@@ -244,6 +262,7 @@
 				MutableObjectId id = new MutableObjectId();
 				List<PathEdit> wtEdits = new ArrayList<PathEdit>();
 				List<String> wtDeletes = new ArrayList<String>();
+				List<DirCacheEntry> untracked = new ArrayList<DirCacheEntry>();
 				boolean hasChanges = false;
 				do {
 					AbstractTreeIterator headIter = treeWalk.getTree(0,
@@ -258,7 +277,8 @@
 								new UnmergedPathException(
 										indexIter.getDirCacheEntry()));
 					if (wtIter != null) {
-						if (indexIter == null && headIter == null)
+						if (indexIter == null && headIter == null
+								&& !includeUntracked)
 							continue;
 						hasChanges = true;
 						if (indexIter != null && wtIter.idEqual(indexIter))
@@ -279,11 +299,15 @@
 						} finally {
 							in.close();
 						}
-						wtEdits.add(new PathEdit(entry) {
-							public void apply(DirCacheEntry ent) {
-								ent.copyMetaData(entry);
-							}
-						});
+
+						if (indexIter == null && headIter == null)
+							untracked.add(entry);
+						else
+							wtEdits.add(new PathEdit(entry) {
+								public void apply(DirCacheEntry ent) {
+									ent.copyMetaData(entry);
+								}
+							});
 					}
 					hasChanges = true;
 					if (wtIter == null && headIter != null)
@@ -297,13 +321,32 @@
 						.getName());
 
 				// Commit index changes
-				CommitBuilder builder = createBuilder(headCommit);
+				CommitBuilder builder = createBuilder();
+				builder.setParentId(headCommit);
 				builder.setTreeId(cache.writeTree(inserter));
 				builder.setMessage(MessageFormat.format(indexMessage, branch,
 						headCommit.abbreviate(7).name(),
 						headCommit.getShortMessage()));
 				ObjectId indexCommit = inserter.insert(builder);
 
+				// Commit untracked changes
+				ObjectId untrackedCommit = null;
+				if (!untracked.isEmpty()) {
+					DirCache untrackedDirCache = DirCache.newInCore();
+					DirCacheBuilder untrackedBuilder = untrackedDirCache
+							.builder();
+					for (DirCacheEntry entry : untracked)
+						untrackedBuilder.add(entry);
+					untrackedBuilder.finish();
+
+					builder.setParentIds(new ObjectId[0]);
+					builder.setTreeId(untrackedDirCache.writeTree(inserter));
+					builder.setMessage(MessageFormat.format(MSG_UNTRACKED,
+							branch, headCommit.abbreviate(7).name(),
+							headCommit.getShortMessage()));
+					untrackedCommit = inserter.insert(builder);
+				}
+
 				// Commit working tree changes
 				if (!wtEdits.isEmpty() || !wtDeletes.isEmpty()) {
 					DirCacheEditor editor = cache.editor();
@@ -313,7 +356,10 @@
 						editor.add(new DeletePath(path));
 					editor.finish();
 				}
+				builder.setParentId(headCommit);
 				builder.addParentId(indexCommit);
+				if (untrackedCommit != null)
+					builder.addParentId(untrackedCommit);
 				builder.setMessage(MessageFormat.format(
 						workingDirectoryMessage, branch,
 						headCommit.abbreviate(7).name(),
@@ -324,6 +370,16 @@
 
 				updateStashRef(commitId, builder.getAuthor(),
 						builder.getMessage());
+
+				// Remove untracked files
+				if (includeUntracked) {
+					for (DirCacheEntry entry : untracked) {
+						File file = new File(repo.getWorkTree(),
+								entry.getPathString());
+						FileUtils.delete(file);
+					}
+				}
+
 			} finally {
 				inserter.release();
 				cache.unlock();
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
index 39f203c..8acfb54 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
@@ -538,7 +538,7 @@
 	/***/ public String stashApplyFailed;
 	/***/ public String stashApplyWithoutHead;
 	/***/ public String stashApplyOnUnsafeRepository;
-	/***/ public String stashCommitMissingTwoParents;
+	/***/ public String stashCommitIncorrectNumberOfParents;
 	/***/ public String stashDropDeleteRefFailed;
 	/***/ public String stashDropFailed;
 	/***/ public String stashDropMissingReflog;