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>
This commit is contained in:
Andreas Hermann 2014-05-08 14:04:32 +02:00 committed by Matthias Sohn
parent b7e46c07f9
commit 44f81d956b
6 changed files with 295 additions and 18 deletions

View File

@ -608,7 +608,8 @@ public void unstashNonStashCommit() throws Exception {
fail("Exception not thrown"); fail("Exception not thrown");
} catch (JGitInternalException e) { } catch (JGitInternalException e) {
assertEquals(MessageFormat.format( assertEquals(MessageFormat.format(
JGitText.get().stashCommitMissingTwoParents, head.name()), JGitText.get().stashCommitIncorrectNumberOfParents,
head.name(), 0),
e.getMessage()); e.getMessage());
} }
} }
@ -648,4 +649,91 @@ public void testApplyStashWithDeletedFile() throws Exception {
assertFalse(file.exists()); 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));
}
} }

View File

@ -80,6 +80,8 @@ public class StashCreateCommandTest extends RepositoryTestCase {
private File committedFile; private File committedFile;
private File untrackedFile;
@Before @Before
public void setUp() throws Exception { public void setUp() throws Exception {
super.setUp(); super.setUp();
@ -88,16 +90,24 @@ public void setUp() throws Exception {
git.add().addFilepattern("file.txt").call(); git.add().addFilepattern("file.txt").call();
head = git.commit().setMessage("add file").call(); head = git.commit().setMessage("add file").call();
assertNotNull(head); 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 * Core validation to be performed on all stashed commits
* *
* @param commit * @param commit
* @param parentCount
* number of parent commits required
* @throws IOException * @throws IOException
*/ */
private void validateStashedCommit(final RevCommit commit) private void validateStashedCommit(final RevCommit commit,
int parentCount)
throws IOException { throws IOException {
assertNotNull(commit); assertNotNull(commit);
Ref stashRef = db.getRef(Constants.R_STASH); Ref stashRef = db.getRef(Constants.R_STASH);
@ -105,7 +115,7 @@ private void validateStashedCommit(final RevCommit commit)
assertEquals(commit, stashRef.getObjectId()); assertEquals(commit, stashRef.getObjectId());
assertNotNull(commit.getAuthorIdent()); assertNotNull(commit.getAuthorIdent());
assertEquals(commit.getAuthorIdent(), commit.getCommitterIdent()); assertEquals(commit.getAuthorIdent(), commit.getCommitterIdent());
assertEquals(2, commit.getParentCount()); assertEquals(parentCount, commit.getParentCount());
// Load parents // Load parents
RevWalk walk = new RevWalk(db); RevWalk walk = new RevWalk(db);
@ -461,4 +471,35 @@ public void unmergedPathsShouldCauseException() throws Exception {
git.stashCreate().call(); 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));
}
} }

View File

@ -476,7 +476,7 @@ stashApplyConflictInIndex=Applying stashed index changes resulted in a conflict.
stashApplyFailed=Applying stashed changes did not successfully complete stashApplyFailed=Applying stashed changes did not successfully complete
stashApplyOnUnsafeRepository=Cannot apply stashed commit on a repository with state: {0} 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 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} stashDropDeleteRefFailed=Deleting stash reference failed with result: {0}
stashDropFailed=Dropping stashed commit failed stashDropFailed=Dropping stashed commit failed
stashDropMissingReflog=Stash reflog does not contain entry ''{0}'' stashDropMissingReflog=Stash reflog does not contain entry ''{0}''

View File

@ -42,6 +42,7 @@
*/ */
package org.eclipse.jgit.api; package org.eclipse.jgit.api;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.text.MessageFormat; import java.text.MessageFormat;
@ -56,6 +57,7 @@
import org.eclipse.jgit.dircache.DirCacheCheckout; import org.eclipse.jgit.dircache.DirCacheCheckout;
import org.eclipse.jgit.dircache.DirCacheEntry; import org.eclipse.jgit.dircache.DirCacheEntry;
import org.eclipse.jgit.dircache.DirCacheIterator; import org.eclipse.jgit.dircache.DirCacheIterator;
import org.eclipse.jgit.errors.CheckoutConflictException;
import org.eclipse.jgit.internal.JGitText; import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectId;
@ -90,6 +92,8 @@ public class StashApplyCommand extends GitCommand<ObjectId> {
private boolean applyIndex = true; private boolean applyIndex = true;
private boolean applyUntracked = true;
private boolean ignoreRepositoryState; private boolean ignoreRepositoryState;
private MergeStrategy strategy = MergeStrategy.RECURSIVE; private MergeStrategy strategy = MergeStrategy.RECURSIVE;
@ -173,15 +177,20 @@ public ObjectId call() throws GitAPIException,
final ObjectId stashId = getStashId(); final ObjectId stashId = getStashId();
RevCommit stashCommit = revWalk.parseCommit(stashId); RevCommit stashCommit = revWalk.parseCommit(stashId);
if (stashCommit.getParentCount() != 2) if (stashCommit.getParentCount() < 2
|| stashCommit.getParentCount() > 3)
throw new JGitInternalException(MessageFormat.format( throw new JGitInternalException(MessageFormat.format(
JGitText.get().stashCommitMissingTwoParents, JGitText.get().stashCommitIncorrectNumberOfParents,
stashId.name())); stashId.name(),
Integer.valueOf(stashCommit.getParentCount())));
ObjectId headTree = repo.resolve(Constants.HEAD + "^{tree}"); //$NON-NLS-1$ ObjectId headTree = repo.resolve(Constants.HEAD + "^{tree}"); //$NON-NLS-1$
ObjectId stashIndexCommit = revWalk.parseCommit(stashCommit ObjectId stashIndexCommit = revWalk.parseCommit(stashCommit
.getParent(1)); .getParent(1));
ObjectId stashHeadCommit = stashCommit.getParent(0); 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); ResolveMerger merger = (ResolveMerger) strategy.newMerger(repo);
merger.setCommitNames(new String[] { "stashed HEAD", "HEAD", merger.setCommitNames(new String[] { "stashed HEAD", "HEAD",
@ -209,6 +218,29 @@ public ObjectId call() throws GitAPIException,
JGitText.get().stashApplyConflict); 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 { } else {
throw new StashApplyFailureException( throw new StashApplyFailureException(
JGitText.get().stashApplyConflict); JGitText.get().stashApplyConflict);
@ -244,6 +276,15 @@ public StashApplyCommand setStrategy(MergeStrategy strategy) {
return this; 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 { private void resetIndex(RevTree tree) throws IOException {
DirCache dc = repo.lockDirCache(); DirCache dc = repo.lockDirCache();
TreeWalk walk = null; TreeWalk walk = null;
@ -285,4 +326,55 @@ private void resetIndex(RevTree tree) throws IOException {
walk.release(); 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);
}
}
} }

View File

@ -42,6 +42,7 @@
*/ */
package org.eclipse.jgit.api; package org.eclipse.jgit.api;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.text.MessageFormat; import java.text.MessageFormat;
@ -54,6 +55,7 @@
import org.eclipse.jgit.api.errors.NoHeadException; import org.eclipse.jgit.api.errors.NoHeadException;
import org.eclipse.jgit.api.errors.UnmergedPathsException; import org.eclipse.jgit.api.errors.UnmergedPathsException;
import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCache;
import org.eclipse.jgit.dircache.DirCacheBuilder;
import org.eclipse.jgit.dircache.DirCacheEditor; import org.eclipse.jgit.dircache.DirCacheEditor;
import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath; import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit; import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
@ -80,6 +82,7 @@
import org.eclipse.jgit.treewalk.filter.AndTreeFilter; import org.eclipse.jgit.treewalk.filter.AndTreeFilter;
import org.eclipse.jgit.treewalk.filter.IndexDiffFilter; import org.eclipse.jgit.treewalk.filter.IndexDiffFilter;
import org.eclipse.jgit.treewalk.filter.SkipWorkTreeFilter; 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 * Command class to stash changes in the working directory and index in a
@ -93,6 +96,8 @@ public class StashCreateCommand extends GitCommand<RevCommit> {
private static final String MSG_INDEX = "index on {0}: {1} {2}"; 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 static final String MSG_WORKING_DIR = "WIP on {0}: {1} {2}";
private String indexMessage = MSG_INDEX; private String indexMessage = MSG_INDEX;
@ -103,6 +108,8 @@ public class StashCreateCommand extends GitCommand<RevCommit> {
private PersonIdent person; private PersonIdent person;
private boolean includeUntracked;
/** /**
* Create a command to stash changes in the working directory and index * Create a command to stash changes in the working directory and index
* *
@ -166,6 +173,18 @@ public StashCreateCommand setRef(String ref) {
return this; 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, private RevCommit parseCommit(final ObjectReader reader,
final ObjectId headId) throws IOException { final ObjectId headId) throws IOException {
final RevWalk walk = new RevWalk(reader); final RevWalk walk = new RevWalk(reader);
@ -173,14 +192,13 @@ private RevCommit parseCommit(final ObjectReader reader,
return walk.parseCommit(headId); return walk.parseCommit(headId);
} }
private CommitBuilder createBuilder(ObjectId headId) { private CommitBuilder createBuilder() {
CommitBuilder builder = new CommitBuilder(); CommitBuilder builder = new CommitBuilder();
PersonIdent author = person; PersonIdent author = person;
if (author == null) if (author == null)
author = new PersonIdent(repo); author = new PersonIdent(repo);
builder.setAuthor(author); builder.setAuthor(author);
builder.setCommitter(author); builder.setCommitter(author);
builder.setParentId(headId);
return builder; return builder;
} }
@ -244,6 +262,7 @@ public RevCommit call() throws GitAPIException {
MutableObjectId id = new MutableObjectId(); MutableObjectId id = new MutableObjectId();
List<PathEdit> wtEdits = new ArrayList<PathEdit>(); List<PathEdit> wtEdits = new ArrayList<PathEdit>();
List<String> wtDeletes = new ArrayList<String>(); List<String> wtDeletes = new ArrayList<String>();
List<DirCacheEntry> untracked = new ArrayList<DirCacheEntry>();
boolean hasChanges = false; boolean hasChanges = false;
do { do {
AbstractTreeIterator headIter = treeWalk.getTree(0, AbstractTreeIterator headIter = treeWalk.getTree(0,
@ -258,7 +277,8 @@ public RevCommit call() throws GitAPIException {
new UnmergedPathException( new UnmergedPathException(
indexIter.getDirCacheEntry())); indexIter.getDirCacheEntry()));
if (wtIter != null) { if (wtIter != null) {
if (indexIter == null && headIter == null) if (indexIter == null && headIter == null
&& !includeUntracked)
continue; continue;
hasChanges = true; hasChanges = true;
if (indexIter != null && wtIter.idEqual(indexIter)) if (indexIter != null && wtIter.idEqual(indexIter))
@ -279,6 +299,10 @@ public RevCommit call() throws GitAPIException {
} finally { } finally {
in.close(); in.close();
} }
if (indexIter == null && headIter == null)
untracked.add(entry);
else
wtEdits.add(new PathEdit(entry) { wtEdits.add(new PathEdit(entry) {
public void apply(DirCacheEntry ent) { public void apply(DirCacheEntry ent) {
ent.copyMetaData(entry); ent.copyMetaData(entry);
@ -297,13 +321,32 @@ public void apply(DirCacheEntry ent) {
.getName()); .getName());
// Commit index changes // Commit index changes
CommitBuilder builder = createBuilder(headCommit); CommitBuilder builder = createBuilder();
builder.setParentId(headCommit);
builder.setTreeId(cache.writeTree(inserter)); builder.setTreeId(cache.writeTree(inserter));
builder.setMessage(MessageFormat.format(indexMessage, branch, builder.setMessage(MessageFormat.format(indexMessage, branch,
headCommit.abbreviate(7).name(), headCommit.abbreviate(7).name(),
headCommit.getShortMessage())); headCommit.getShortMessage()));
ObjectId indexCommit = inserter.insert(builder); 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 // Commit working tree changes
if (!wtEdits.isEmpty() || !wtDeletes.isEmpty()) { if (!wtEdits.isEmpty() || !wtDeletes.isEmpty()) {
DirCacheEditor editor = cache.editor(); DirCacheEditor editor = cache.editor();
@ -313,7 +356,10 @@ public void apply(DirCacheEntry ent) {
editor.add(new DeletePath(path)); editor.add(new DeletePath(path));
editor.finish(); editor.finish();
} }
builder.setParentId(headCommit);
builder.addParentId(indexCommit); builder.addParentId(indexCommit);
if (untrackedCommit != null)
builder.addParentId(untrackedCommit);
builder.setMessage(MessageFormat.format( builder.setMessage(MessageFormat.format(
workingDirectoryMessage, branch, workingDirectoryMessage, branch,
headCommit.abbreviate(7).name(), headCommit.abbreviate(7).name(),
@ -324,6 +370,16 @@ public void apply(DirCacheEntry ent) {
updateStashRef(commitId, builder.getAuthor(), updateStashRef(commitId, builder.getAuthor(),
builder.getMessage()); builder.getMessage());
// Remove untracked files
if (includeUntracked) {
for (DirCacheEntry entry : untracked) {
File file = new File(repo.getWorkTree(),
entry.getPathString());
FileUtils.delete(file);
}
}
} finally { } finally {
inserter.release(); inserter.release();
cache.unlock(); cache.unlock();

View File

@ -538,7 +538,7 @@ public static JGitText get() {
/***/ public String stashApplyFailed; /***/ public String stashApplyFailed;
/***/ public String stashApplyWithoutHead; /***/ public String stashApplyWithoutHead;
/***/ public String stashApplyOnUnsafeRepository; /***/ public String stashApplyOnUnsafeRepository;
/***/ public String stashCommitMissingTwoParents; /***/ public String stashCommitIncorrectNumberOfParents;
/***/ public String stashDropDeleteRefFailed; /***/ public String stashDropDeleteRefFailed;
/***/ public String stashDropFailed; /***/ public String stashDropFailed;
/***/ public String stashDropMissingReflog; /***/ public String stashDropMissingReflog;