From 44f81d956b3036804d011e7d99769fbd904d5082 Mon Sep 17 00:00:00 2001 From: Andreas Hermann Date: Thu, 8 May 2014 14:04:32 +0200 Subject: [PATCH] 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 --- .../jgit/api/StashApplyCommandTest.java | 90 ++++++++++++++++- .../jgit/api/StashCreateCommandTest.java | 47 ++++++++- .../eclipse/jgit/internal/JGitText.properties | 2 +- .../eclipse/jgit/api/StashApplyCommand.java | 98 ++++++++++++++++++- .../eclipse/jgit/api/StashCreateCommand.java | 74 ++++++++++++-- .../org/eclipse/jgit/internal/JGitText.java | 2 +- 6 files changed, 295 insertions(+), 18 deletions(-) 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 283410038..95b14192c 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 @@ public void unstashNonStashCommit() throws Exception { 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 @@ public void testApplyStashWithDeletedFile() throws Exception { 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 030dc9f4a..387120342 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 @@ public class StashCreateCommandTest extends RepositoryTestCase { private File committedFile; + private File untrackedFile; + @Before public void setUp() throws Exception { super.setUp(); @@ -88,16 +90,24 @@ public void setUp() throws Exception { 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 @@ private void validateStashedCommit(final RevCommit commit) 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 @@ public void unmergedPathsShouldCauseException() throws Exception { 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 7b073217a..fd5801e6a 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 @@ stashApplyConflictInIndex=Applying stashed index changes resulted in a conflict. 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 f73ce831f..d935857ce 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 @@ public class StashApplyCommand extends GitCommand { private boolean applyIndex = true; + private boolean applyUntracked = true; + private boolean ignoreRepositoryState; private MergeStrategy strategy = MergeStrategy.RECURSIVE; @@ -173,15 +177,20 @@ public ObjectId call() throws GitAPIException, 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 @@ public ObjectId call() throws GitAPIException, 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 @@ public StashApplyCommand setStrategy(MergeStrategy strategy) { 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 @@ private void resetIndex(RevTree tree) throws IOException { 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 cf0b6d1d9..af35f772c 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 @@ public class StashCreateCommand extends GitCommand { 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 @@ public class StashCreateCommand extends GitCommand { private PersonIdent person; + private boolean includeUntracked; + /** * Create a command to stash changes in the working directory and index * @@ -166,6 +173,18 @@ public StashCreateCommand setRef(String ref) { 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 @@ private RevCommit parseCommit(final ObjectReader reader, 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 @@ public RevCommit call() throws GitAPIException { MutableObjectId id = new MutableObjectId(); List wtEdits = new ArrayList(); List wtDeletes = new ArrayList(); + List untracked = new ArrayList(); boolean hasChanges = false; do { AbstractTreeIterator headIter = treeWalk.getTree(0, @@ -258,7 +277,8 @@ public RevCommit call() throws GitAPIException { 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 @@ public RevCommit call() throws GitAPIException { } 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 @@ public void apply(DirCacheEntry ent) { .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 @@ public void apply(DirCacheEntry ent) { 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 @@ public void apply(DirCacheEntry ent) { 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 39f203ca8..8acfb54b8 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 static JGitText get() { /***/ public String stashApplyFailed; /***/ public String stashApplyWithoutHead; /***/ public String stashApplyOnUnsafeRepository; - /***/ public String stashCommitMissingTwoParents; + /***/ public String stashCommitIncorrectNumberOfParents; /***/ public String stashDropDeleteRefFailed; /***/ public String stashDropFailed; /***/ public String stashDropMissingReflog;